Merge "Correct English grammar in linkstoimage"
[lhc/web/wiklou.git] / includes / parser / Parser.php
1 <?php
2 /**
3 * PHP parser that converts wiki markup to HTML.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Parser
22 */
23 use MediaWiki\Config\ServiceOptions;
24 use MediaWiki\Linker\LinkRenderer;
25 use MediaWiki\Linker\LinkRendererFactory;
26 use MediaWiki\Linker\LinkTarget;
27 use MediaWiki\MediaWikiServices;
28 use MediaWiki\Special\SpecialPageFactory;
29 use Psr\Log\NullLogger;
30 use Wikimedia\ScopedCallback;
31 use Psr\Log\LoggerInterface;
32
33 /**
34 * @defgroup Parser Parser
35 */
36
37 /**
38 * PHP Parser - Processes wiki markup (which uses a more user-friendly
39 * syntax, such as "[[link]]" for making links), and provides a one-way
40 * transformation of that wiki markup it into (X)HTML output / markup
41 * (which in turn the browser understands, and can display).
42 *
43 * There are seven main entry points into the Parser class:
44 *
45 * - Parser::parse()
46 * produces HTML output
47 * - Parser::preSaveTransform()
48 * produces altered wiki markup
49 * - Parser::preprocess()
50 * removes HTML comments and expands templates
51 * - Parser::cleanSig() and Parser::cleanSigInSig()
52 * cleans a signature before saving it to preferences
53 * - Parser::getSection()
54 * return the content of a section from an article for section editing
55 * - Parser::replaceSection()
56 * replaces a section by number inside an article
57 * - Parser::getPreloadText()
58 * removes <noinclude> sections and <includeonly> tags
59 *
60 * @warning $wgUser or $wgTitle or $wgRequest or $wgLang. Keep them away!
61 *
62 * @par Settings:
63 * $wgNamespacesWithSubpages
64 *
65 * @par Settings only within ParserOptions:
66 * $wgAllowExternalImages
67 * $wgAllowSpecialInclusion
68 * $wgInterwikiMagic
69 * $wgMaxArticleSize
70 *
71 * @ingroup Parser
72 */
73 class Parser {
74 /**
75 * Update this version number when the ParserOutput format
76 * changes in an incompatible way, so the parser cache
77 * can automatically discard old data.
78 */
79 const VERSION = '1.6.4';
80
81 /**
82 * Update this version number when the output of serialiseHalfParsedText()
83 * changes in an incompatible way
84 */
85 const HALF_PARSED_VERSION = 2;
86
87 # Flags for Parser::setFunctionHook
88 const SFH_NO_HASH = 1;
89 const SFH_OBJECT_ARGS = 2;
90
91 # Constants needed for external link processing
92 # Everything except bracket, space, or control characters
93 # \p{Zs} is unicode 'separator, space' category. It covers the space 0x20
94 # as well as U+3000 is IDEOGRAPHIC SPACE for T21052
95 # \x{FFFD} is the Unicode replacement character, which Preprocessor_DOM
96 # uses to replace invalid HTML characters.
97 const EXT_LINK_URL_CLASS = '[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]';
98 # Simplified expression to match an IPv4 or IPv6 address, or
99 # at least one character of a host name (embeds EXT_LINK_URL_CLASS)
100 const EXT_LINK_ADDR = '(?:[0-9.]+|\\[(?i:[0-9a-f:.]+)\\]|[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}])';
101 # RegExp to make image URLs (embeds IPv6 part of EXT_LINK_ADDR)
102 // phpcs:ignore Generic.Files.LineLength
103 const EXT_IMAGE_REGEX = '/^(http:\/\/|https:\/\/)((?:\\[(?i:[0-9a-f:.]+)\\])?[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]+)
104 \\/([A-Za-z0-9_.,~%\\-+&;#*?!=()@\\x80-\\xFF]+)\\.((?i)gif|png|jpg|jpeg)$/Sxu';
105
106 # Regular expression for a non-newline space
107 const SPACE_NOT_NL = '(?:\t|&nbsp;|&\#0*160;|&\#[Xx]0*[Aa]0;|\p{Zs})';
108
109 # Flags for preprocessToDom
110 const PTD_FOR_INCLUSION = 1;
111
112 # Allowed values for $this->mOutputType
113 # Parameter to startExternalParse().
114 const OT_HTML = 1; # like parse()
115 const OT_WIKI = 2; # like preSaveTransform()
116 const OT_PREPROCESS = 3; # like preprocess()
117 const OT_MSG = 3;
118 const OT_PLAIN = 4; # like extractSections() - portions of the original are returned unchanged.
119
120 /**
121 * @var string Prefix and suffix for temporary replacement strings
122 * for the multipass parser.
123 *
124 * \x7f should never appear in input as it's disallowed in XML.
125 * Using it at the front also gives us a little extra robustness
126 * since it shouldn't match when butted up against identifier-like
127 * string constructs.
128 *
129 * Must not consist of all title characters, or else it will change
130 * the behavior of <nowiki> in a link.
131 *
132 * Must have a character that needs escaping in attributes, otherwise
133 * someone could put a strip marker in an attribute, to get around
134 * escaping quote marks, and break out of the attribute. Thus we add
135 * `'".
136 */
137 const MARKER_SUFFIX = "-QINU`\"'\x7f";
138 const MARKER_PREFIX = "\x7f'\"`UNIQ-";
139
140 # Markers used for wrapping the table of contents
141 const TOC_START = '<mw:toc>';
142 const TOC_END = '</mw:toc>';
143
144 /** @var int Assume that no output will later be saved this many seconds after parsing */
145 const MAX_TTS = 900;
146
147 # Persistent:
148 public $mTagHooks = [];
149 public $mTransparentTagHooks = [];
150 public $mFunctionHooks = [];
151 public $mFunctionSynonyms = [ 0 => [], 1 => [] ];
152 public $mFunctionTagHooks = [];
153 public $mStripList = [];
154 public $mDefaultStripList = [];
155 public $mVarCache = [];
156 public $mImageParams = [];
157 public $mImageParamsMagicArray = [];
158 public $mMarkerIndex = 0;
159 /**
160 * @var bool Whether firstCallInit still needs to be called
161 */
162 public $mFirstCall = true;
163
164 # Initialised by initialiseVariables()
165
166 /**
167 * @var MagicWordArray
168 */
169 public $mVariables;
170
171 /**
172 * @var MagicWordArray
173 */
174 public $mSubstWords;
175
176 /**
177 * @deprecated since 1.34, there should be no need to use this
178 * @var array
179 */
180 public $mConf;
181
182 # Initialised in constructor
183 public $mExtLinkBracketedRegex, $mUrlProtocols;
184
185 # Initialized in getPreprocessor()
186 /** @var Preprocessor */
187 public $mPreprocessor;
188
189 # Cleared with clearState():
190 /**
191 * @var ParserOutput
192 */
193 public $mOutput;
194 public $mAutonumber;
195
196 /**
197 * @var StripState
198 */
199 public $mStripState;
200
201 public $mIncludeCount;
202 /**
203 * @var LinkHolderArray
204 */
205 public $mLinkHolders;
206
207 public $mLinkID;
208 public $mIncludeSizes, $mPPNodeCount, $mGeneratedPPNodeCount, $mHighestExpansionDepth;
209 public $mDefaultSort;
210 public $mTplRedirCache, $mHeadings, $mDoubleUnderscores;
211 public $mExpensiveFunctionCount; # number of expensive parser function calls
212 public $mShowToc, $mForceTocPosition;
213 /** @var array */
214 public $mTplDomCache;
215
216 /**
217 * @var User
218 */
219 public $mUser; # User object; only used when doing pre-save transform
220
221 # Temporary
222 # These are variables reset at least once per parse regardless of $clearState
223
224 /**
225 * @var ParserOptions
226 */
227 public $mOptions;
228
229 /**
230 * @var Title
231 */
232 public $mTitle; # Title context, used for self-link rendering and similar things
233 public $mOutputType; # Output type, one of the OT_xxx constants
234 public $ot; # Shortcut alias, see setOutputType()
235 public $mRevisionObject; # The revision object of the specified revision ID
236 public $mRevisionId; # ID to display in {{REVISIONID}} tags
237 public $mRevisionTimestamp; # The timestamp of the specified revision ID
238 public $mRevisionUser; # User to display in {{REVISIONUSER}} tag
239 public $mRevisionSize; # Size to display in {{REVISIONSIZE}} variable
240 public $mRevIdForTs; # The revision ID which was used to fetch the timestamp
241 public $mInputSize = false; # For {{PAGESIZE}} on current page.
242
243 /**
244 * @var array Array with the language name of each language link (i.e. the
245 * interwiki prefix) in the key, value arbitrary. Used to avoid sending
246 * duplicate language links to the ParserOutput.
247 */
248 public $mLangLinkLanguages;
249
250 /**
251 * @var MapCacheLRU|null
252 * @since 1.24
253 *
254 * A cache of the current revisions of titles. Keys are $title->getPrefixedDbKey()
255 */
256 public $currentRevisionCache;
257
258 /**
259 * @var bool|string Recursive call protection.
260 * This variable should be treated as if it were private.
261 */
262 public $mInParse = false;
263
264 /** @var SectionProfiler */
265 protected $mProfiler;
266
267 /**
268 * @var LinkRenderer
269 */
270 protected $mLinkRenderer;
271
272 /** @var MagicWordFactory */
273 private $magicWordFactory;
274
275 /** @var Language */
276 private $contLang;
277
278 /** @var ParserFactory */
279 private $factory;
280
281 /** @var SpecialPageFactory */
282 private $specialPageFactory;
283
284 /**
285 * This is called $svcOptions instead of $options like elsewhere to avoid confusion with
286 * $mOptions, which is public and widely used, and also with the local variable $options used
287 * for ParserOptions throughout this file.
288 *
289 * @var ServiceOptions
290 */
291 private $svcOptions;
292
293 /** @var LinkRendererFactory */
294 private $linkRendererFactory;
295
296 /** @var NamespaceInfo */
297 private $nsInfo;
298
299 /** @var LoggerInterface */
300 private $logger;
301
302 /**
303 * TODO Make this a const when HHVM support is dropped (T192166)
304 *
305 * @var array
306 * @since 1.33
307 */
308 public static $constructorOptions = [
309 // See $wgParserConf documentation
310 'class',
311 'preprocessorClass',
312 // See documentation for the corresponding config options
313 'ArticlePath',
314 'EnableScaryTranscluding',
315 'ExtraInterlanguageLinkPrefixes',
316 'FragmentMode',
317 'LanguageCode',
318 'MaxSigChars',
319 'MaxTocLevel',
320 'MiserMode',
321 'ScriptPath',
322 'Server',
323 'ServerName',
324 'ShowHostnames',
325 'Sitename',
326 'StylePath',
327 'TranscludeCacheExpiry',
328 ];
329
330 /**
331 * Constructing parsers directly is deprecated! Use a ParserFactory.
332 *
333 * @param ServiceOptions|null $svcOptions
334 * @param MagicWordFactory|null $magicWordFactory
335 * @param Language|null $contLang Content language
336 * @param ParserFactory|null $factory
337 * @param string|null $urlProtocols As returned from wfUrlProtocols()
338 * @param SpecialPageFactory|null $spFactory
339 * @param LinkRendererFactory|null $linkRendererFactory
340 * @param NamespaceInfo|null $nsInfo
341 * @param LoggerInterface|null $logger
342 */
343 public function __construct(
344 $svcOptions = null,
345 MagicWordFactory $magicWordFactory = null,
346 Language $contLang = null,
347 ParserFactory $factory = null,
348 $urlProtocols = null,
349 SpecialPageFactory $spFactory = null,
350 $linkRendererFactory = null,
351 $nsInfo = null,
352 $logger = null
353 ) {
354 $services = MediaWikiServices::getInstance();
355 if ( !$svcOptions || is_array( $svcOptions ) ) {
356 // Pre-1.34 calling convention is the first parameter is just ParserConf, the seventh is
357 // Config, and the eighth is LinkRendererFactory.
358 $this->mConf = (array)$svcOptions;
359 if ( empty( $this->mConf['class'] ) ) {
360 $this->mConf['class'] = self::class;
361 }
362 if ( empty( $this->mConf['preprocessorClass'] ) ) {
363 $this->mConf['preprocessorClass'] = self::getDefaultPreprocessorClass();
364 }
365 $this->svcOptions = new ServiceOptions( self::$constructorOptions,
366 $this->mConf,
367 func_num_args() > 6 ? func_get_arg( 6 ) : $services->getMainConfig()
368 );
369 $linkRendererFactory = func_num_args() > 7 ? func_get_arg( 7 ) : null;
370 $nsInfo = func_num_args() > 8 ? func_get_arg( 8 ) : null;
371 } else {
372 // New calling convention
373 $svcOptions->assertRequiredOptions( self::$constructorOptions );
374 // $this->mConf is public, so we'll keep those two options there as well for
375 // compatibility until it's removed
376 $this->mConf = [
377 'class' => $svcOptions->get( 'class' ),
378 'preprocessorClass' => $svcOptions->get( 'preprocessorClass' ),
379 ];
380 $this->svcOptions = $svcOptions;
381 }
382
383 $this->mUrlProtocols = $urlProtocols ?? wfUrlProtocols();
384 $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' .
385 self::EXT_LINK_ADDR .
386 self::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su';
387
388 $this->magicWordFactory = $magicWordFactory ??
389 $services->getMagicWordFactory();
390
391 $this->contLang = $contLang ?? $services->getContentLanguage();
392
393 $this->factory = $factory ?? $services->getParserFactory();
394 $this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory();
395 $this->linkRendererFactory = $linkRendererFactory ?? $services->getLinkRendererFactory();
396 $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
397 $this->logger = $logger ?: new NullLogger();
398 }
399
400 /**
401 * Reduce memory usage to reduce the impact of circular references
402 */
403 public function __destruct() {
404 if ( isset( $this->mLinkHolders ) ) {
405 unset( $this->mLinkHolders );
406 }
407 foreach ( $this as $name => $value ) {
408 unset( $this->$name );
409 }
410 }
411
412 /**
413 * Allow extensions to clean up when the parser is cloned
414 */
415 public function __clone() {
416 $this->mInParse = false;
417
418 // T58226: When you create a reference "to" an object field, that
419 // makes the object field itself be a reference too (until the other
420 // reference goes out of scope). When cloning, any field that's a
421 // reference is copied as a reference in the new object. Both of these
422 // are defined PHP5 behaviors, as inconvenient as it is for us when old
423 // hooks from PHP4 days are passing fields by reference.
424 foreach ( [ 'mStripState', 'mVarCache' ] as $k ) {
425 // Make a non-reference copy of the field, then rebind the field to
426 // reference the new copy.
427 $tmp = $this->$k;
428 $this->$k =& $tmp;
429 unset( $tmp );
430 }
431
432 Hooks::run( 'ParserCloned', [ $this ] );
433 }
434
435 /**
436 * Which class should we use for the preprocessor if not otherwise specified?
437 *
438 * @since 1.34
439 * @deprecated since 1.34, removing configurability of preprocessor
440 * @return string
441 */
442 public static function getDefaultPreprocessorClass() {
443 return Preprocessor_Hash::class;
444 }
445
446 /**
447 * Do various kinds of initialisation on the first call of the parser
448 */
449 public function firstCallInit() {
450 if ( !$this->mFirstCall ) {
451 return;
452 }
453 $this->mFirstCall = false;
454
455 CoreParserFunctions::register( $this );
456 CoreTagHooks::register( $this );
457 $this->initialiseVariables();
458
459 // Avoid PHP 7.1 warning from passing $this by reference
460 $parser = $this;
461 Hooks::run( 'ParserFirstCallInit', [ &$parser ] );
462 }
463
464 /**
465 * Clear Parser state
466 *
467 * @private
468 */
469 public function clearState() {
470 $this->firstCallInit();
471 $this->mOutput = new ParserOutput;
472 $this->mOptions->registerWatcher( [ $this->mOutput, 'recordOption' ] );
473 $this->mAutonumber = 0;
474 $this->mIncludeCount = [];
475 $this->mLinkHolders = new LinkHolderArray( $this );
476 $this->mLinkID = 0;
477 $this->mRevisionObject = $this->mRevisionTimestamp =
478 $this->mRevisionId = $this->mRevisionUser = $this->mRevisionSize = null;
479 $this->mVarCache = [];
480 $this->mUser = null;
481 $this->mLangLinkLanguages = [];
482 $this->currentRevisionCache = null;
483
484 $this->mStripState = new StripState( $this );
485
486 # Clear these on every parse, T6549
487 $this->mTplRedirCache = $this->mTplDomCache = [];
488
489 $this->mShowToc = true;
490 $this->mForceTocPosition = false;
491 $this->mIncludeSizes = [
492 'post-expand' => 0,
493 'arg' => 0,
494 ];
495 $this->mPPNodeCount = 0;
496 $this->mGeneratedPPNodeCount = 0;
497 $this->mHighestExpansionDepth = 0;
498 $this->mDefaultSort = false;
499 $this->mHeadings = [];
500 $this->mDoubleUnderscores = [];
501 $this->mExpensiveFunctionCount = 0;
502
503 # Fix cloning
504 if ( isset( $this->mPreprocessor ) && $this->mPreprocessor->parser !== $this ) {
505 $this->mPreprocessor = null;
506 }
507
508 $this->mProfiler = new SectionProfiler();
509
510 // Avoid PHP 7.1 warning from passing $this by reference
511 $parser = $this;
512 Hooks::run( 'ParserClearState', [ &$parser ] );
513 }
514
515 /**
516 * Convert wikitext to HTML
517 * Do not call this function recursively.
518 *
519 * @param string $text Text we want to parse
520 * @param-taint $text escapes_htmlnoent
521 * @param Title $title
522 * @param ParserOptions $options
523 * @param bool $linestart
524 * @param bool $clearState
525 * @param int|null $revid Number to pass in {{REVISIONID}}
526 * @return ParserOutput A ParserOutput
527 * @return-taint escaped
528 */
529 public function parse(
530 $text, Title $title, ParserOptions $options,
531 $linestart = true, $clearState = true, $revid = null
532 ) {
533 if ( $clearState ) {
534 // We use U+007F DELETE to construct strip markers, so we have to make
535 // sure that this character does not occur in the input text.
536 $text = strtr( $text, "\x7f", "?" );
537 $magicScopeVariable = $this->lock();
538 }
539 // Strip U+0000 NULL (T159174)
540 $text = str_replace( "\000", '', $text );
541
542 $this->startParse( $title, $options, self::OT_HTML, $clearState );
543
544 $this->currentRevisionCache = null;
545 $this->mInputSize = strlen( $text );
546 if ( $this->mOptions->getEnableLimitReport() ) {
547 $this->mOutput->resetParseStartTime();
548 }
549
550 $oldRevisionId = $this->mRevisionId;
551 $oldRevisionObject = $this->mRevisionObject;
552 $oldRevisionTimestamp = $this->mRevisionTimestamp;
553 $oldRevisionUser = $this->mRevisionUser;
554 $oldRevisionSize = $this->mRevisionSize;
555 if ( $revid !== null ) {
556 $this->mRevisionId = $revid;
557 $this->mRevisionObject = null;
558 $this->mRevisionTimestamp = null;
559 $this->mRevisionUser = null;
560 $this->mRevisionSize = null;
561 }
562
563 // Avoid PHP 7.1 warning from passing $this by reference
564 $parser = $this;
565 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
566 # No more strip!
567 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
568 $text = $this->internalParse( $text );
569 Hooks::run( 'ParserAfterParse', [ &$parser, &$text, &$this->mStripState ] );
570
571 $text = $this->internalParseHalfParsed( $text, true, $linestart );
572
573 /**
574 * A converted title will be provided in the output object if title and
575 * content conversion are enabled, the article text does not contain
576 * a conversion-suppressing double-underscore tag, and no
577 * {{DISPLAYTITLE:...}} is present. DISPLAYTITLE takes precedence over
578 * automatic link conversion.
579 */
580 if ( !( $options->getDisableTitleConversion()
581 || isset( $this->mDoubleUnderscores['nocontentconvert'] )
582 || isset( $this->mDoubleUnderscores['notitleconvert'] )
583 || $this->mOutput->getDisplayTitle() !== false )
584 ) {
585 $convruletitle = $this->getTargetLanguage()->getConvRuleTitle();
586 if ( $convruletitle ) {
587 $this->mOutput->setTitleText( $convruletitle );
588 } else {
589 $titleText = $this->getTargetLanguage()->convertTitle( $title );
590 $this->mOutput->setTitleText( $titleText );
591 }
592 }
593
594 # Compute runtime adaptive expiry if set
595 $this->mOutput->finalizeAdaptiveCacheExpiry();
596
597 # Warn if too many heavyweight parser functions were used
598 if ( $this->mExpensiveFunctionCount > $this->mOptions->getExpensiveParserFunctionLimit() ) {
599 $this->limitationWarn( 'expensive-parserfunction',
600 $this->mExpensiveFunctionCount,
601 $this->mOptions->getExpensiveParserFunctionLimit()
602 );
603 }
604
605 # Information on limits, for the benefit of users who try to skirt them
606 if ( $this->mOptions->getEnableLimitReport() ) {
607 $text .= $this->makeLimitReport();
608 }
609
610 # Wrap non-interface parser output in a <div> so it can be targeted
611 # with CSS (T37247)
612 $class = $this->mOptions->getWrapOutputClass();
613 if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
614 $this->mOutput->addWrapperDivClass( $class );
615 }
616
617 $this->mOutput->setText( $text );
618
619 $this->mRevisionId = $oldRevisionId;
620 $this->mRevisionObject = $oldRevisionObject;
621 $this->mRevisionTimestamp = $oldRevisionTimestamp;
622 $this->mRevisionUser = $oldRevisionUser;
623 $this->mRevisionSize = $oldRevisionSize;
624 $this->mInputSize = false;
625 $this->currentRevisionCache = null;
626
627 return $this->mOutput;
628 }
629
630 /**
631 * Set the limit report data in the current ParserOutput, and return the
632 * limit report HTML comment.
633 *
634 * @return string
635 */
636 protected function makeLimitReport() {
637 $maxIncludeSize = $this->mOptions->getMaxIncludeSize();
638
639 $cpuTime = $this->mOutput->getTimeSinceStart( 'cpu' );
640 if ( $cpuTime !== null ) {
641 $this->mOutput->setLimitReportData( 'limitreport-cputime',
642 sprintf( "%.3f", $cpuTime )
643 );
644 }
645
646 $wallTime = $this->mOutput->getTimeSinceStart( 'wall' );
647 $this->mOutput->setLimitReportData( 'limitreport-walltime',
648 sprintf( "%.3f", $wallTime )
649 );
650
651 $this->mOutput->setLimitReportData( 'limitreport-ppvisitednodes',
652 [ $this->mPPNodeCount, $this->mOptions->getMaxPPNodeCount() ]
653 );
654 $this->mOutput->setLimitReportData( 'limitreport-ppgeneratednodes',
655 [ $this->mGeneratedPPNodeCount, $this->mOptions->getMaxGeneratedPPNodeCount() ]
656 );
657 $this->mOutput->setLimitReportData( 'limitreport-postexpandincludesize',
658 [ $this->mIncludeSizes['post-expand'], $maxIncludeSize ]
659 );
660 $this->mOutput->setLimitReportData( 'limitreport-templateargumentsize',
661 [ $this->mIncludeSizes['arg'], $maxIncludeSize ]
662 );
663 $this->mOutput->setLimitReportData( 'limitreport-expansiondepth',
664 [ $this->mHighestExpansionDepth, $this->mOptions->getMaxPPExpandDepth() ]
665 );
666 $this->mOutput->setLimitReportData( 'limitreport-expensivefunctioncount',
667 [ $this->mExpensiveFunctionCount, $this->mOptions->getExpensiveParserFunctionLimit() ]
668 );
669
670 foreach ( $this->mStripState->getLimitReport() as list( $key, $value ) ) {
671 $this->mOutput->setLimitReportData( $key, $value );
672 }
673
674 Hooks::run( 'ParserLimitReportPrepare', [ $this, $this->mOutput ] );
675
676 $limitReport = "NewPP limit report\n";
677 if ( $this->svcOptions->get( 'ShowHostnames' ) ) {
678 $limitReport .= 'Parsed by ' . wfHostname() . "\n";
679 }
680 $limitReport .= 'Cached time: ' . $this->mOutput->getCacheTime() . "\n";
681 $limitReport .= 'Cache expiry: ' . $this->mOutput->getCacheExpiry() . "\n";
682 $limitReport .= 'Dynamic content: ' .
683 ( $this->mOutput->hasDynamicContent() ? 'true' : 'false' ) .
684 "\n";
685 $limitReport .= 'Complications: [' . implode( ', ', $this->mOutput->getAllFlags() ) . "]\n";
686
687 foreach ( $this->mOutput->getLimitReportData() as $key => $value ) {
688 if ( Hooks::run( 'ParserLimitReportFormat',
689 [ $key, &$value, &$limitReport, false, false ]
690 ) ) {
691 $keyMsg = wfMessage( $key )->inLanguage( 'en' )->useDatabase( false );
692 $valueMsg = wfMessage( [ "$key-value-text", "$key-value" ] )
693 ->inLanguage( 'en' )->useDatabase( false );
694 if ( !$valueMsg->exists() ) {
695 $valueMsg = new RawMessage( '$1' );
696 }
697 if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
698 $valueMsg->params( $value );
699 $limitReport .= "{$keyMsg->text()}: {$valueMsg->text()}\n";
700 }
701 }
702 }
703 // Since we're not really outputting HTML, decode the entities and
704 // then re-encode the things that need hiding inside HTML comments.
705 $limitReport = htmlspecialchars_decode( $limitReport );
706
707 // Sanitize for comment. Note '‐' in the replacement is U+2010,
708 // which looks much like the problematic '-'.
709 $limitReport = str_replace( [ '-', '&' ], [ '‐', '&amp;' ], $limitReport );
710 $text = "\n<!-- \n$limitReport-->\n";
711
712 // Add on template profiling data in human/machine readable way
713 $dataByFunc = $this->mProfiler->getFunctionStats();
714 uasort( $dataByFunc, function ( $a, $b ) {
715 return $b['real'] <=> $a['real']; // descending order
716 } );
717 $profileReport = [];
718 foreach ( array_slice( $dataByFunc, 0, 10 ) as $item ) {
719 $profileReport[] = sprintf( "%6.2f%% %8.3f %6d %s",
720 $item['%real'], $item['real'], $item['calls'],
721 htmlspecialchars( $item['name'] ) );
722 }
723 $text .= "<!--\nTransclusion expansion time report (%,ms,calls,template)\n";
724 $text .= implode( "\n", $profileReport ) . "\n-->\n";
725
726 $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport );
727
728 // Add other cache related metadata
729 if ( $this->svcOptions->get( 'ShowHostnames' ) ) {
730 $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() );
731 }
732 $this->mOutput->setLimitReportData( 'cachereport-timestamp',
733 $this->mOutput->getCacheTime() );
734 $this->mOutput->setLimitReportData( 'cachereport-ttl',
735 $this->mOutput->getCacheExpiry() );
736 $this->mOutput->setLimitReportData( 'cachereport-transientcontent',
737 $this->mOutput->hasDynamicContent() );
738
739 if ( $this->mGeneratedPPNodeCount > $this->mOptions->getMaxGeneratedPPNodeCount() / 10 ) {
740 wfDebugLog( 'generated-pp-node-count', $this->mGeneratedPPNodeCount . ' ' .
741 $this->mTitle->getPrefixedDBkey() );
742 }
743 return $text;
744 }
745
746 /**
747 * Half-parse wikitext to half-parsed HTML. This recursive parser entry point
748 * can be called from an extension tag hook.
749 *
750 * The output of this function IS NOT SAFE PARSED HTML; it is "half-parsed"
751 * instead, which means that lists and links have not been fully parsed yet,
752 * and strip markers are still present.
753 *
754 * Use recursiveTagParseFully() to fully parse wikitext to output-safe HTML.
755 *
756 * Use this function if you're a parser tag hook and you want to parse
757 * wikitext before or after applying additional transformations, and you
758 * intend to *return the result as hook output*, which will cause it to go
759 * through the rest of parsing process automatically.
760 *
761 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
762 * $text are not expanded
763 *
764 * @param string $text Text extension wants to have parsed
765 * @param-taint $text escapes_htmlnoent
766 * @param bool|PPFrame $frame The frame to use for expanding any template variables
767 * @return string UNSAFE half-parsed HTML
768 * @return-taint escaped
769 */
770 public function recursiveTagParse( $text, $frame = false ) {
771 // Avoid PHP 7.1 warning from passing $this by reference
772 $parser = $this;
773 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
774 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
775 $text = $this->internalParse( $text, false, $frame );
776 return $text;
777 }
778
779 /**
780 * Fully parse wikitext to fully parsed HTML. This recursive parser entry
781 * point can be called from an extension tag hook.
782 *
783 * The output of this function is fully-parsed HTML that is safe for output.
784 * If you're a parser tag hook, you might want to use recursiveTagParse()
785 * instead.
786 *
787 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
788 * $text are not expanded
789 *
790 * @since 1.25
791 *
792 * @param string $text Text extension wants to have parsed
793 * @param-taint $text escapes_htmlnoent
794 * @param bool|PPFrame $frame The frame to use for expanding any template variables
795 * @return string Fully parsed HTML
796 * @return-taint escaped
797 */
798 public function recursiveTagParseFully( $text, $frame = false ) {
799 $text = $this->recursiveTagParse( $text, $frame );
800 $text = $this->internalParseHalfParsed( $text, false );
801 return $text;
802 }
803
804 /**
805 * Expand templates and variables in the text, producing valid, static wikitext.
806 * Also removes comments.
807 * Do not call this function recursively.
808 * @param string $text
809 * @param Title|null $title
810 * @param ParserOptions $options
811 * @param int|null $revid
812 * @param bool|PPFrame $frame
813 * @return mixed|string
814 */
815 public function preprocess( $text, Title $title = null,
816 ParserOptions $options, $revid = null, $frame = false
817 ) {
818 $magicScopeVariable = $this->lock();
819 $this->startParse( $title, $options, self::OT_PREPROCESS, true );
820 if ( $revid !== null ) {
821 $this->mRevisionId = $revid;
822 }
823 // Avoid PHP 7.1 warning from passing $this by reference
824 $parser = $this;
825 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
826 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
827 $text = $this->replaceVariables( $text, $frame );
828 $text = $this->mStripState->unstripBoth( $text );
829 return $text;
830 }
831
832 /**
833 * Recursive parser entry point that can be called from an extension tag
834 * hook.
835 *
836 * @param string $text Text to be expanded
837 * @param bool|PPFrame $frame The frame to use for expanding any template variables
838 * @return string
839 * @since 1.19
840 */
841 public function recursivePreprocess( $text, $frame = false ) {
842 $text = $this->replaceVariables( $text, $frame );
843 $text = $this->mStripState->unstripBoth( $text );
844 return $text;
845 }
846
847 /**
848 * Process the wikitext for the "?preload=" feature. (T7210)
849 *
850 * "<noinclude>", "<includeonly>" etc. are parsed as for template
851 * transclusion, comments, templates, arguments, tags hooks and parser
852 * functions are untouched.
853 *
854 * @param string $text
855 * @param Title $title
856 * @param ParserOptions $options
857 * @param array $params
858 * @return string
859 */
860 public function getPreloadText( $text, Title $title, ParserOptions $options, $params = [] ) {
861 $msg = new RawMessage( $text );
862 $text = $msg->params( $params )->plain();
863
864 # Parser (re)initialisation
865 $magicScopeVariable = $this->lock();
866 $this->startParse( $title, $options, self::OT_PLAIN, true );
867
868 $flags = PPFrame::NO_ARGS | PPFrame::NO_TEMPLATES;
869 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
870 $text = $this->getPreprocessor()->newFrame()->expand( $dom, $flags );
871 $text = $this->mStripState->unstripBoth( $text );
872 return $text;
873 }
874
875 /**
876 * Set the current user.
877 * Should only be used when doing pre-save transform.
878 *
879 * @param User|null $user User object or null (to reset)
880 */
881 public function setUser( $user ) {
882 $this->mUser = $user;
883 }
884
885 /**
886 * Set the context title
887 *
888 * @param Title $t
889 */
890 public function setTitle( $t ) {
891 if ( !$t ) {
892 $t = Title::newFromText( 'NO TITLE' );
893 }
894
895 if ( $t->hasFragment() ) {
896 # Strip the fragment to avoid various odd effects
897 $this->mTitle = $t->createFragmentTarget( '' );
898 } else {
899 $this->mTitle = $t;
900 }
901 }
902
903 /**
904 * Accessor for the Title object
905 *
906 * @return Title|null
907 */
908 public function getTitle() {
909 return $this->mTitle;
910 }
911
912 /**
913 * Accessor/mutator for the Title object
914 *
915 * @param Title|null $x Title object or null to just get the current one
916 * @return Title
917 */
918 public function Title( $x = null ) {
919 return wfSetVar( $this->mTitle, $x );
920 }
921
922 /**
923 * Set the output type
924 *
925 * @param int $ot New value
926 */
927 public function setOutputType( $ot ) {
928 $this->mOutputType = $ot;
929 # Shortcut alias
930 $this->ot = [
931 'html' => $ot == self::OT_HTML,
932 'wiki' => $ot == self::OT_WIKI,
933 'pre' => $ot == self::OT_PREPROCESS,
934 'plain' => $ot == self::OT_PLAIN,
935 ];
936 }
937
938 /**
939 * Accessor/mutator for the output type
940 *
941 * @param int|null $x New value or null to just get the current one
942 * @return int
943 */
944 public function OutputType( $x = null ) {
945 return wfSetVar( $this->mOutputType, $x );
946 }
947
948 /**
949 * Get the ParserOutput object
950 *
951 * @return ParserOutput
952 */
953 public function getOutput() {
954 return $this->mOutput;
955 }
956
957 /**
958 * Get the ParserOptions object
959 *
960 * @return ParserOptions
961 */
962 public function getOptions() {
963 return $this->mOptions;
964 }
965
966 /**
967 * Accessor/mutator for the ParserOptions object
968 *
969 * @param ParserOptions|null $x New value or null to just get the current one
970 * @return ParserOptions Current ParserOptions object
971 */
972 public function Options( $x = null ) {
973 return wfSetVar( $this->mOptions, $x );
974 }
975
976 /**
977 * @return int
978 */
979 public function nextLinkID() {
980 return $this->mLinkID++;
981 }
982
983 /**
984 * @param int $id
985 */
986 public function setLinkID( $id ) {
987 $this->mLinkID = $id;
988 }
989
990 /**
991 * Get a language object for use in parser functions such as {{FORMATNUM:}}
992 * @return Language
993 */
994 public function getFunctionLang() {
995 return $this->getTargetLanguage();
996 }
997
998 /**
999 * Get the target language for the content being parsed. This is usually the
1000 * language that the content is in.
1001 *
1002 * @since 1.19
1003 *
1004 * @throws MWException
1005 * @return Language
1006 */
1007 public function getTargetLanguage() {
1008 $target = $this->mOptions->getTargetLanguage();
1009
1010 if ( $target !== null ) {
1011 return $target;
1012 } elseif ( $this->mOptions->getInterfaceMessage() ) {
1013 return $this->mOptions->getUserLangObj();
1014 } elseif ( is_null( $this->mTitle ) ) {
1015 throw new MWException( __METHOD__ . ': $this->mTitle is null' );
1016 }
1017
1018 return $this->mTitle->getPageLanguage();
1019 }
1020
1021 /**
1022 * Get the language object for language conversion
1023 * @deprecated since 1.32, just use getTargetLanguage()
1024 * @return Language|null
1025 */
1026 public function getConverterLanguage() {
1027 return $this->getTargetLanguage();
1028 }
1029
1030 /**
1031 * Get a User object either from $this->mUser, if set, or from the
1032 * ParserOptions object otherwise
1033 *
1034 * @return User
1035 */
1036 public function getUser() {
1037 if ( !is_null( $this->mUser ) ) {
1038 return $this->mUser;
1039 }
1040 return $this->mOptions->getUser();
1041 }
1042
1043 /**
1044 * Get a preprocessor object
1045 *
1046 * @return Preprocessor
1047 */
1048 public function getPreprocessor() {
1049 if ( !isset( $this->mPreprocessor ) ) {
1050 $class = $this->svcOptions->get( 'preprocessorClass' );
1051 $this->mPreprocessor = new $class( $this );
1052 }
1053 return $this->mPreprocessor;
1054 }
1055
1056 /**
1057 * Get a LinkRenderer instance to make links with
1058 *
1059 * @since 1.28
1060 * @return LinkRenderer
1061 */
1062 public function getLinkRenderer() {
1063 // XXX We make the LinkRenderer with current options and then cache it forever
1064 if ( !$this->mLinkRenderer ) {
1065 $this->mLinkRenderer = $this->linkRendererFactory->create();
1066 $this->mLinkRenderer->setStubThreshold(
1067 $this->getOptions()->getStubThreshold()
1068 );
1069 }
1070
1071 return $this->mLinkRenderer;
1072 }
1073
1074 /**
1075 * Get the MagicWordFactory that this Parser is using
1076 *
1077 * @since 1.32
1078 * @return MagicWordFactory
1079 */
1080 public function getMagicWordFactory() {
1081 return $this->magicWordFactory;
1082 }
1083
1084 /**
1085 * Get the content language that this Parser is using
1086 *
1087 * @since 1.32
1088 * @return Language
1089 */
1090 public function getContentLanguage() {
1091 return $this->contLang;
1092 }
1093
1094 /**
1095 * Replaces all occurrences of HTML-style comments and the given tags
1096 * in the text with a random marker and returns the next text. The output
1097 * parameter $matches will be an associative array filled with data in
1098 * the form:
1099 *
1100 * @code
1101 * 'UNIQ-xxxxx' => [
1102 * 'element',
1103 * 'tag content',
1104 * [ 'param' => 'x' ],
1105 * '<element param="x">tag content</element>' ]
1106 * @endcode
1107 *
1108 * @param array $elements List of element names. Comments are always extracted.
1109 * @param string $text Source text string.
1110 * @param array &$matches Out parameter, Array: extracted tags
1111 * @return string Stripped text
1112 */
1113 public static function extractTagsAndParams( $elements, $text, &$matches ) {
1114 static $n = 1;
1115 $stripped = '';
1116 $matches = [];
1117
1118 $taglist = implode( '|', $elements );
1119 $start = "/<($taglist)(\\s+[^>]*?|\\s*?)(\/?>)|<(!--)/i";
1120
1121 while ( $text != '' ) {
1122 $p = preg_split( $start, $text, 2, PREG_SPLIT_DELIM_CAPTURE );
1123 $stripped .= $p[0];
1124 if ( count( $p ) < 5 ) {
1125 break;
1126 }
1127 if ( count( $p ) > 5 ) {
1128 # comment
1129 $element = $p[4];
1130 $attributes = '';
1131 $close = '';
1132 $inside = $p[5];
1133 } else {
1134 # tag
1135 list( , $element, $attributes, $close, $inside ) = $p;
1136 }
1137
1138 $marker = self::MARKER_PREFIX . "-$element-" . sprintf( '%08X', $n++ ) . self::MARKER_SUFFIX;
1139 $stripped .= $marker;
1140
1141 if ( $close === '/>' ) {
1142 # Empty element tag, <tag />
1143 $content = null;
1144 $text = $inside;
1145 $tail = null;
1146 } else {
1147 if ( $element === '!--' ) {
1148 $end = '/(-->)/';
1149 } else {
1150 $end = "/(<\\/$element\\s*>)/i";
1151 }
1152 $q = preg_split( $end, $inside, 2, PREG_SPLIT_DELIM_CAPTURE );
1153 $content = $q[0];
1154 if ( count( $q ) < 3 ) {
1155 # No end tag -- let it run out to the end of the text.
1156 $tail = '';
1157 $text = '';
1158 } else {
1159 list( , $tail, $text ) = $q;
1160 }
1161 }
1162
1163 $matches[$marker] = [ $element,
1164 $content,
1165 Sanitizer::decodeTagAttributes( $attributes ),
1166 "<$element$attributes$close$content$tail" ];
1167 }
1168 return $stripped;
1169 }
1170
1171 /**
1172 * Get a list of strippable XML-like elements
1173 *
1174 * @return array
1175 */
1176 public function getStripList() {
1177 return $this->mStripList;
1178 }
1179
1180 /**
1181 * Add an item to the strip state
1182 * Returns the unique tag which must be inserted into the stripped text
1183 * The tag will be replaced with the original text in unstrip()
1184 *
1185 * @param string $text
1186 *
1187 * @return string
1188 */
1189 public function insertStripItem( $text ) {
1190 $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX;
1191 $this->mMarkerIndex++;
1192 $this->mStripState->addGeneral( $marker, $text );
1193 return $marker;
1194 }
1195
1196 /**
1197 * parse the wiki syntax used to render tables
1198 *
1199 * @private
1200 * @param string $text
1201 * @return string
1202 */
1203 public function doTableStuff( $text ) {
1204 $lines = StringUtils::explode( "\n", $text );
1205 $out = '';
1206 $td_history = []; # Is currently a td tag open?
1207 $last_tag_history = []; # Save history of last lag activated (td, th or caption)
1208 $tr_history = []; # Is currently a tr tag open?
1209 $tr_attributes = []; # history of tr attributes
1210 $has_opened_tr = []; # Did this table open a <tr> element?
1211 $indent_level = 0; # indent level of the table
1212
1213 foreach ( $lines as $outLine ) {
1214 $line = trim( $outLine );
1215
1216 if ( $line === '' ) { # empty line, go to next line
1217 $out .= $outLine . "\n";
1218 continue;
1219 }
1220
1221 $first_character = $line[0];
1222 $first_two = substr( $line, 0, 2 );
1223 $matches = [];
1224
1225 if ( preg_match( '/^(:*)\s*\{\|(.*)$/', $line, $matches ) ) {
1226 # First check if we are starting a new table
1227 $indent_level = strlen( $matches[1] );
1228
1229 $attributes = $this->mStripState->unstripBoth( $matches[2] );
1230 $attributes = Sanitizer::fixTagAttributes( $attributes, 'table' );
1231
1232 $outLine = str_repeat( '<dl><dd>', $indent_level ) . "<table{$attributes}>";
1233 array_push( $td_history, false );
1234 array_push( $last_tag_history, '' );
1235 array_push( $tr_history, false );
1236 array_push( $tr_attributes, '' );
1237 array_push( $has_opened_tr, false );
1238 } elseif ( count( $td_history ) == 0 ) {
1239 # Don't do any of the following
1240 $out .= $outLine . "\n";
1241 continue;
1242 } elseif ( $first_two === '|}' ) {
1243 # We are ending a table
1244 $line = '</table>' . substr( $line, 2 );
1245 $last_tag = array_pop( $last_tag_history );
1246
1247 if ( !array_pop( $has_opened_tr ) ) {
1248 $line = "<tr><td></td></tr>{$line}";
1249 }
1250
1251 if ( array_pop( $tr_history ) ) {
1252 $line = "</tr>{$line}";
1253 }
1254
1255 if ( array_pop( $td_history ) ) {
1256 $line = "</{$last_tag}>{$line}";
1257 }
1258 array_pop( $tr_attributes );
1259 if ( $indent_level > 0 ) {
1260 $outLine = rtrim( $line ) . str_repeat( '</dd></dl>', $indent_level );
1261 } else {
1262 $outLine = $line;
1263 }
1264 } elseif ( $first_two === '|-' ) {
1265 # Now we have a table row
1266 $line = preg_replace( '#^\|-+#', '', $line );
1267
1268 # Whats after the tag is now only attributes
1269 $attributes = $this->mStripState->unstripBoth( $line );
1270 $attributes = Sanitizer::fixTagAttributes( $attributes, 'tr' );
1271 array_pop( $tr_attributes );
1272 array_push( $tr_attributes, $attributes );
1273
1274 $line = '';
1275 $last_tag = array_pop( $last_tag_history );
1276 array_pop( $has_opened_tr );
1277 array_push( $has_opened_tr, true );
1278
1279 if ( array_pop( $tr_history ) ) {
1280 $line = '</tr>';
1281 }
1282
1283 if ( array_pop( $td_history ) ) {
1284 $line = "</{$last_tag}>{$line}";
1285 }
1286
1287 $outLine = $line;
1288 array_push( $tr_history, false );
1289 array_push( $td_history, false );
1290 array_push( $last_tag_history, '' );
1291 } elseif ( $first_character === '|'
1292 || $first_character === '!'
1293 || $first_two === '|+'
1294 ) {
1295 # This might be cell elements, td, th or captions
1296 if ( $first_two === '|+' ) {
1297 $first_character = '+';
1298 $line = substr( $line, 2 );
1299 } else {
1300 $line = substr( $line, 1 );
1301 }
1302
1303 // Implies both are valid for table headings.
1304 if ( $first_character === '!' ) {
1305 $line = StringUtils::replaceMarkup( '!!', '||', $line );
1306 }
1307
1308 # Split up multiple cells on the same line.
1309 # FIXME : This can result in improper nesting of tags processed
1310 # by earlier parser steps.
1311 $cells = explode( '||', $line );
1312
1313 $outLine = '';
1314
1315 # Loop through each table cell
1316 foreach ( $cells as $cell ) {
1317 $previous = '';
1318 if ( $first_character !== '+' ) {
1319 $tr_after = array_pop( $tr_attributes );
1320 if ( !array_pop( $tr_history ) ) {
1321 $previous = "<tr{$tr_after}>\n";
1322 }
1323 array_push( $tr_history, true );
1324 array_push( $tr_attributes, '' );
1325 array_pop( $has_opened_tr );
1326 array_push( $has_opened_tr, true );
1327 }
1328
1329 $last_tag = array_pop( $last_tag_history );
1330
1331 if ( array_pop( $td_history ) ) {
1332 $previous = "</{$last_tag}>\n{$previous}";
1333 }
1334
1335 if ( $first_character === '|' ) {
1336 $last_tag = 'td';
1337 } elseif ( $first_character === '!' ) {
1338 $last_tag = 'th';
1339 } elseif ( $first_character === '+' ) {
1340 $last_tag = 'caption';
1341 } else {
1342 $last_tag = '';
1343 }
1344
1345 array_push( $last_tag_history, $last_tag );
1346
1347 # A cell could contain both parameters and data
1348 $cell_data = explode( '|', $cell, 2 );
1349
1350 # T2553: Note that a '|' inside an invalid link should not
1351 # be mistaken as delimiting cell parameters
1352 # Bug T153140: Neither should language converter markup.
1353 if ( preg_match( '/\[\[|-\{/', $cell_data[0] ) === 1 ) {
1354 $cell = "{$previous}<{$last_tag}>" . trim( $cell );
1355 } elseif ( count( $cell_data ) == 1 ) {
1356 // Whitespace in cells is trimmed
1357 $cell = "{$previous}<{$last_tag}>" . trim( $cell_data[0] );
1358 } else {
1359 $attributes = $this->mStripState->unstripBoth( $cell_data[0] );
1360 $attributes = Sanitizer::fixTagAttributes( $attributes, $last_tag );
1361 // Whitespace in cells is trimmed
1362 $cell = "{$previous}<{$last_tag}{$attributes}>" . trim( $cell_data[1] );
1363 }
1364
1365 $outLine .= $cell;
1366 array_push( $td_history, true );
1367 }
1368 }
1369 $out .= $outLine . "\n";
1370 }
1371
1372 # Closing open td, tr && table
1373 while ( count( $td_history ) > 0 ) {
1374 if ( array_pop( $td_history ) ) {
1375 $out .= "</td>\n";
1376 }
1377 if ( array_pop( $tr_history ) ) {
1378 $out .= "</tr>\n";
1379 }
1380 if ( !array_pop( $has_opened_tr ) ) {
1381 $out .= "<tr><td></td></tr>\n";
1382 }
1383
1384 $out .= "</table>\n";
1385 }
1386
1387 # Remove trailing line-ending (b/c)
1388 if ( substr( $out, -1 ) === "\n" ) {
1389 $out = substr( $out, 0, -1 );
1390 }
1391
1392 # special case: don't return empty table
1393 if ( $out === "<table>\n<tr><td></td></tr>\n</table>" ) {
1394 $out = '';
1395 }
1396
1397 return $out;
1398 }
1399
1400 /**
1401 * Helper function for parse() that transforms wiki markup into half-parsed
1402 * HTML. Only called for $mOutputType == self::OT_HTML.
1403 *
1404 * @private
1405 *
1406 * @param string $text The text to parse
1407 * @param-taint $text escapes_html
1408 * @param bool $isMain Whether this is being called from the main parse() function
1409 * @param PPFrame|bool $frame A pre-processor frame
1410 *
1411 * @return string
1412 */
1413 public function internalParse( $text, $isMain = true, $frame = false ) {
1414 $origText = $text;
1415
1416 // Avoid PHP 7.1 warning from passing $this by reference
1417 $parser = $this;
1418
1419 # Hook to suspend the parser in this state
1420 if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$parser, &$text, &$this->mStripState ] ) ) {
1421 return $text;
1422 }
1423
1424 # if $frame is provided, then use $frame for replacing any variables
1425 if ( $frame ) {
1426 # use frame depth to infer how include/noinclude tags should be handled
1427 # depth=0 means this is the top-level document; otherwise it's an included document
1428 if ( !$frame->depth ) {
1429 $flag = 0;
1430 } else {
1431 $flag = self::PTD_FOR_INCLUSION;
1432 }
1433 $dom = $this->preprocessToDom( $text, $flag );
1434 $text = $frame->expand( $dom );
1435 } else {
1436 # if $frame is not provided, then use old-style replaceVariables
1437 $text = $this->replaceVariables( $text );
1438 }
1439
1440 Hooks::run( 'InternalParseBeforeSanitize', [ &$parser, &$text, &$this->mStripState ] );
1441 $text = Sanitizer::removeHTMLtags(
1442 $text,
1443 [ $this, 'attributeStripCallback' ],
1444 false,
1445 array_keys( $this->mTransparentTagHooks ),
1446 [],
1447 [ $this, 'addTrackingCategory' ]
1448 );
1449 Hooks::run( 'InternalParseBeforeLinks', [ &$parser, &$text, &$this->mStripState ] );
1450
1451 # Tables need to come after variable replacement for things to work
1452 # properly; putting them before other transformations should keep
1453 # exciting things like link expansions from showing up in surprising
1454 # places.
1455 $text = $this->doTableStuff( $text );
1456
1457 $text = preg_replace( '/(^|\n)-----*/', '\\1<hr />', $text );
1458
1459 $text = $this->doDoubleUnderscore( $text );
1460
1461 $text = $this->doHeadings( $text );
1462 $text = $this->replaceInternalLinks( $text );
1463 $text = $this->doAllQuotes( $text );
1464 $text = $this->replaceExternalLinks( $text );
1465
1466 # replaceInternalLinks may sometimes leave behind
1467 # absolute URLs, which have to be masked to hide them from replaceExternalLinks
1468 $text = str_replace( self::MARKER_PREFIX . 'NOPARSE', '', $text );
1469
1470 $text = $this->doMagicLinks( $text );
1471 $text = $this->formatHeadings( $text, $origText, $isMain );
1472
1473 return $text;
1474 }
1475
1476 /**
1477 * Helper function for parse() that transforms half-parsed HTML into fully
1478 * parsed HTML.
1479 *
1480 * @param string $text
1481 * @param bool $isMain
1482 * @param bool $linestart
1483 * @return string
1484 */
1485 private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
1486 $text = $this->mStripState->unstripGeneral( $text );
1487
1488 // Avoid PHP 7.1 warning from passing $this by reference
1489 $parser = $this;
1490
1491 if ( $isMain ) {
1492 Hooks::run( 'ParserAfterUnstrip', [ &$parser, &$text ] );
1493 }
1494
1495 # Clean up special characters, only run once, next-to-last before doBlockLevels
1496 $text = Sanitizer::armorFrenchSpaces( $text );
1497
1498 $text = $this->doBlockLevels( $text, $linestart );
1499
1500 $this->replaceLinkHolders( $text );
1501
1502 /**
1503 * The input doesn't get language converted if
1504 * a) It's disabled
1505 * b) Content isn't converted
1506 * c) It's a conversion table
1507 * d) it is an interface message (which is in the user language)
1508 */
1509 if ( !( $this->mOptions->getDisableContentConversion()
1510 || isset( $this->mDoubleUnderscores['nocontentconvert'] ) )
1511 && !$this->mOptions->getInterfaceMessage()
1512 ) {
1513 # The position of the convert() call should not be changed. it
1514 # assumes that the links are all replaced and the only thing left
1515 # is the <nowiki> mark.
1516 $text = $this->getTargetLanguage()->convert( $text );
1517 }
1518
1519 $text = $this->mStripState->unstripNoWiki( $text );
1520
1521 if ( $isMain ) {
1522 Hooks::run( 'ParserBeforeTidy', [ &$parser, &$text ] );
1523 }
1524
1525 $text = $this->replaceTransparentTags( $text );
1526 $text = $this->mStripState->unstripGeneral( $text );
1527
1528 $text = Sanitizer::normalizeCharReferences( $text );
1529
1530 if ( MWTidy::isEnabled() ) {
1531 if ( $this->mOptions->getTidy() ) {
1532 $text = MWTidy::tidy( $text );
1533 }
1534 } else {
1535 # attempt to sanitize at least some nesting problems
1536 # (T4702 and quite a few others)
1537 # This code path is buggy and deprecated!
1538 wfDeprecated( 'disabling tidy', '1.33' );
1539 $tidyregs = [
1540 # ''Something [http://www.cool.com cool''] -->
1541 # <i>Something</i><a href="http://www.cool.com"..><i>cool></i></a>
1542 '/(<([bi])>)(<([bi])>)?([^<]*)(<\/?a[^<]*>)([^<]*)(<\/\\4>)?(<\/\\2>)/' =>
1543 '\\1\\3\\5\\8\\9\\6\\1\\3\\7\\8\\9',
1544 # fix up an anchor inside another anchor, only
1545 # at least for a single single nested link (T5695)
1546 '/(<a[^>]+>)([^<]*)(<a[^>]+>[^<]*)<\/a>(.*)<\/a>/' =>
1547 '\\1\\2</a>\\3</a>\\1\\4</a>',
1548 # fix div inside inline elements- doBlockLevels won't wrap a line which
1549 # contains a div, so fix it up here; replace
1550 # div with escaped text
1551 '/(<([aib]) [^>]+>)([^<]*)(<div([^>]*)>)(.*)(<\/div>)([^<]*)(<\/\\2>)/' =>
1552 '\\1\\3&lt;div\\5&gt;\\6&lt;/div&gt;\\8\\9',
1553 # remove empty italic or bold tag pairs, some
1554 # introduced by rules above
1555 '/<([bi])><\/\\1>/' => '',
1556 ];
1557
1558 $text = preg_replace(
1559 array_keys( $tidyregs ),
1560 array_values( $tidyregs ),
1561 $text );
1562 }
1563
1564 if ( $isMain ) {
1565 Hooks::run( 'ParserAfterTidy', [ &$parser, &$text ] );
1566 }
1567
1568 return $text;
1569 }
1570
1571 /**
1572 * Replace special strings like "ISBN xxx" and "RFC xxx" with
1573 * magic external links.
1574 *
1575 * DML
1576 * @private
1577 *
1578 * @param string $text
1579 *
1580 * @return string
1581 */
1582 public function doMagicLinks( $text ) {
1583 $prots = wfUrlProtocolsWithoutProtRel();
1584 $urlChar = self::EXT_LINK_URL_CLASS;
1585 $addr = self::EXT_LINK_ADDR;
1586 $space = self::SPACE_NOT_NL; # non-newline space
1587 $spdash = "(?:-|$space)"; # a dash or a non-newline space
1588 $spaces = "$space++"; # possessive match of 1 or more spaces
1589 $text = preg_replace_callback(
1590 '!(?: # Start cases
1591 (<a[ \t\r\n>].*?</a>) | # m[1]: Skip link text
1592 (<.*?>) | # m[2]: Skip stuff inside HTML elements' . "
1593 (\b # m[3]: Free external links
1594 (?i:$prots)
1595 ($addr$urlChar*) # m[4]: Post-protocol path
1596 ) |
1597 \b(?:RFC|PMID) $spaces # m[5]: RFC or PMID, capture number
1598 ([0-9]+)\b |
1599 \bISBN $spaces ( # m[6]: ISBN, capture number
1600 (?: 97[89] $spdash? )? # optional 13-digit ISBN prefix
1601 (?: [0-9] $spdash? ){9} # 9 digits with opt. delimiters
1602 [0-9Xx] # check digit
1603 )\b
1604 )!xu", [ $this, 'magicLinkCallback' ], $text );
1605 return $text;
1606 }
1607
1608 /**
1609 * @throws MWException
1610 * @param array $m
1611 * @return string HTML
1612 */
1613 public function magicLinkCallback( $m ) {
1614 if ( isset( $m[1] ) && $m[1] !== '' ) {
1615 # Skip anchor
1616 return $m[0];
1617 } elseif ( isset( $m[2] ) && $m[2] !== '' ) {
1618 # Skip HTML element
1619 return $m[0];
1620 } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
1621 # Free external link
1622 return $this->makeFreeExternalLink( $m[0], strlen( $m[4] ) );
1623 } elseif ( isset( $m[5] ) && $m[5] !== '' ) {
1624 # RFC or PMID
1625 if ( substr( $m[0], 0, 3 ) === 'RFC' ) {
1626 if ( !$this->mOptions->getMagicRFCLinks() ) {
1627 return $m[0];
1628 }
1629 $keyword = 'RFC';
1630 $urlmsg = 'rfcurl';
1631 $cssClass = 'mw-magiclink-rfc';
1632 $trackingCat = 'magiclink-tracking-rfc';
1633 $id = $m[5];
1634 } elseif ( substr( $m[0], 0, 4 ) === 'PMID' ) {
1635 if ( !$this->mOptions->getMagicPMIDLinks() ) {
1636 return $m[0];
1637 }
1638 $keyword = 'PMID';
1639 $urlmsg = 'pubmedurl';
1640 $cssClass = 'mw-magiclink-pmid';
1641 $trackingCat = 'magiclink-tracking-pmid';
1642 $id = $m[5];
1643 } else {
1644 throw new MWException( __METHOD__ . ': unrecognised match type "' .
1645 substr( $m[0], 0, 20 ) . '"' );
1646 }
1647 $url = wfMessage( $urlmsg, $id )->inContentLanguage()->text();
1648 $this->addTrackingCategory( $trackingCat );
1649 return Linker::makeExternalLink( $url, "{$keyword} {$id}", true, $cssClass, [], $this->mTitle );
1650 } elseif ( isset( $m[6] ) && $m[6] !== ''
1651 && $this->mOptions->getMagicISBNLinks()
1652 ) {
1653 # ISBN
1654 $isbn = $m[6];
1655 $space = self::SPACE_NOT_NL; # non-newline space
1656 $isbn = preg_replace( "/$space/", ' ', $isbn );
1657 $num = strtr( $isbn, [
1658 '-' => '',
1659 ' ' => '',
1660 'x' => 'X',
1661 ] );
1662 $this->addTrackingCategory( 'magiclink-tracking-isbn' );
1663 return $this->getLinkRenderer()->makeKnownLink(
1664 SpecialPage::getTitleFor( 'Booksources', $num ),
1665 "ISBN $isbn",
1666 [
1667 'class' => 'internal mw-magiclink-isbn',
1668 'title' => false // suppress title attribute
1669 ]
1670 );
1671 } else {
1672 return $m[0];
1673 }
1674 }
1675
1676 /**
1677 * Make a free external link, given a user-supplied URL
1678 *
1679 * @param string $url
1680 * @param int $numPostProto
1681 * The number of characters after the protocol.
1682 * @return string HTML
1683 * @private
1684 */
1685 public function makeFreeExternalLink( $url, $numPostProto ) {
1686 $trail = '';
1687
1688 # The characters '<' and '>' (which were escaped by
1689 # removeHTMLtags()) should not be included in
1690 # URLs, per RFC 2396.
1691 # Make &nbsp; terminate a URL as well (bug T84937)
1692 $m2 = [];
1693 if ( preg_match(
1694 '/&(lt|gt|nbsp|#x0*(3[CcEe]|[Aa]0)|#0*(60|62|160));/',
1695 $url,
1696 $m2,
1697 PREG_OFFSET_CAPTURE
1698 ) ) {
1699 $trail = substr( $url, $m2[0][1] ) . $trail;
1700 $url = substr( $url, 0, $m2[0][1] );
1701 }
1702
1703 # Move trailing punctuation to $trail
1704 $sep = ',;\.:!?';
1705 # If there is no left bracket, then consider right brackets fair game too
1706 if ( strpos( $url, '(' ) === false ) {
1707 $sep .= ')';
1708 }
1709
1710 $urlRev = strrev( $url );
1711 $numSepChars = strspn( $urlRev, $sep );
1712 # Don't break a trailing HTML entity by moving the ; into $trail
1713 # This is in hot code, so use substr_compare to avoid having to
1714 # create a new string object for the comparison
1715 if ( $numSepChars && substr_compare( $url, ";", -$numSepChars, 1 ) === 0 ) {
1716 # more optimization: instead of running preg_match with a $
1717 # anchor, which can be slow, do the match on the reversed
1718 # string starting at the desired offset.
1719 # un-reversed regexp is: /&([a-z]+|#x[\da-f]+|#\d+)$/i
1720 if ( preg_match( '/\G([a-z]+|[\da-f]+x#|\d+#)&/i', $urlRev, $m2, 0, $numSepChars ) ) {
1721 $numSepChars--;
1722 }
1723 }
1724 if ( $numSepChars ) {
1725 $trail = substr( $url, -$numSepChars ) . $trail;
1726 $url = substr( $url, 0, -$numSepChars );
1727 }
1728
1729 # Verify that we still have a real URL after trail removal, and
1730 # not just lone protocol
1731 if ( strlen( $trail ) >= $numPostProto ) {
1732 return $url . $trail;
1733 }
1734
1735 $url = Sanitizer::cleanUrl( $url );
1736
1737 # Is this an external image?
1738 $text = $this->maybeMakeExternalImage( $url );
1739 if ( $text === false ) {
1740 # Not an image, make a link
1741 $text = Linker::makeExternalLink( $url,
1742 $this->getTargetLanguage()->getConverter()->markNoConversion( $url ),
1743 true, 'free',
1744 $this->getExternalLinkAttribs( $url ), $this->mTitle );
1745 # Register it in the output object...
1746 $this->mOutput->addExternalLink( $url );
1747 }
1748 return $text . $trail;
1749 }
1750
1751 /**
1752 * Parse headers and return html
1753 *
1754 * @private
1755 *
1756 * @param string $text
1757 *
1758 * @return string
1759 */
1760 public function doHeadings( $text ) {
1761 for ( $i = 6; $i >= 1; --$i ) {
1762 $h = str_repeat( '=', $i );
1763 // Trim non-newline whitespace from headings
1764 // Using \s* will break for: "==\n===\n" and parse as <h2>=</h2>
1765 $text = preg_replace( "/^(?:$h)[ \\t]*(.+?)[ \\t]*(?:$h)\\s*$/m", "<h$i>\\1</h$i>", $text );
1766 }
1767 return $text;
1768 }
1769
1770 /**
1771 * Replace single quotes with HTML markup
1772 * @private
1773 *
1774 * @param string $text
1775 *
1776 * @return string The altered text
1777 */
1778 public function doAllQuotes( $text ) {
1779 $outtext = '';
1780 $lines = StringUtils::explode( "\n", $text );
1781 foreach ( $lines as $line ) {
1782 $outtext .= $this->doQuotes( $line ) . "\n";
1783 }
1784 $outtext = substr( $outtext, 0, -1 );
1785 return $outtext;
1786 }
1787
1788 /**
1789 * Helper function for doAllQuotes()
1790 *
1791 * @param string $text
1792 *
1793 * @return string
1794 */
1795 public function doQuotes( $text ) {
1796 $arr = preg_split( "/(''+)/", $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1797 $countarr = count( $arr );
1798 if ( $countarr == 1 ) {
1799 return $text;
1800 }
1801
1802 // First, do some preliminary work. This may shift some apostrophes from
1803 // being mark-up to being text. It also counts the number of occurrences
1804 // of bold and italics mark-ups.
1805 $numbold = 0;
1806 $numitalics = 0;
1807 for ( $i = 1; $i < $countarr; $i += 2 ) {
1808 $thislen = strlen( $arr[$i] );
1809 // If there are ever four apostrophes, assume the first is supposed to
1810 // be text, and the remaining three constitute mark-up for bold text.
1811 // (T15227: ''''foo'''' turns into ' ''' foo ' ''')
1812 if ( $thislen == 4 ) {
1813 $arr[$i - 1] .= "'";
1814 $arr[$i] = "'''";
1815 $thislen = 3;
1816 } elseif ( $thislen > 5 ) {
1817 // If there are more than 5 apostrophes in a row, assume they're all
1818 // text except for the last 5.
1819 // (T15227: ''''''foo'''''' turns into ' ''''' foo ' ''''')
1820 $arr[$i - 1] .= str_repeat( "'", $thislen - 5 );
1821 $arr[$i] = "'''''";
1822 $thislen = 5;
1823 }
1824 // Count the number of occurrences of bold and italics mark-ups.
1825 if ( $thislen == 2 ) {
1826 $numitalics++;
1827 } elseif ( $thislen == 3 ) {
1828 $numbold++;
1829 } elseif ( $thislen == 5 ) {
1830 $numitalics++;
1831 $numbold++;
1832 }
1833 }
1834
1835 // If there is an odd number of both bold and italics, it is likely
1836 // that one of the bold ones was meant to be an apostrophe followed
1837 // by italics. Which one we cannot know for certain, but it is more
1838 // likely to be one that has a single-letter word before it.
1839 if ( ( $numbold % 2 == 1 ) && ( $numitalics % 2 == 1 ) ) {
1840 $firstsingleletterword = -1;
1841 $firstmultiletterword = -1;
1842 $firstspace = -1;
1843 for ( $i = 1; $i < $countarr; $i += 2 ) {
1844 if ( strlen( $arr[$i] ) == 3 ) {
1845 $x1 = substr( $arr[$i - 1], -1 );
1846 $x2 = substr( $arr[$i - 1], -2, 1 );
1847 if ( $x1 === ' ' ) {
1848 if ( $firstspace == -1 ) {
1849 $firstspace = $i;
1850 }
1851 } elseif ( $x2 === ' ' ) {
1852 $firstsingleletterword = $i;
1853 // if $firstsingleletterword is set, we don't
1854 // look at the other options, so we can bail early.
1855 break;
1856 } elseif ( $firstmultiletterword == -1 ) {
1857 $firstmultiletterword = $i;
1858 }
1859 }
1860 }
1861
1862 // If there is a single-letter word, use it!
1863 if ( $firstsingleletterword > -1 ) {
1864 $arr[$firstsingleletterword] = "''";
1865 $arr[$firstsingleletterword - 1] .= "'";
1866 } elseif ( $firstmultiletterword > -1 ) {
1867 // If not, but there's a multi-letter word, use that one.
1868 $arr[$firstmultiletterword] = "''";
1869 $arr[$firstmultiletterword - 1] .= "'";
1870 } elseif ( $firstspace > -1 ) {
1871 // ... otherwise use the first one that has neither.
1872 // (notice that it is possible for all three to be -1 if, for example,
1873 // there is only one pentuple-apostrophe in the line)
1874 $arr[$firstspace] = "''";
1875 $arr[$firstspace - 1] .= "'";
1876 }
1877 }
1878
1879 // Now let's actually convert our apostrophic mush to HTML!
1880 $output = '';
1881 $buffer = '';
1882 $state = '';
1883 $i = 0;
1884 foreach ( $arr as $r ) {
1885 if ( ( $i % 2 ) == 0 ) {
1886 if ( $state === 'both' ) {
1887 $buffer .= $r;
1888 } else {
1889 $output .= $r;
1890 }
1891 } else {
1892 $thislen = strlen( $r );
1893 if ( $thislen == 2 ) {
1894 if ( $state === 'i' ) {
1895 $output .= '</i>';
1896 $state = '';
1897 } elseif ( $state === 'bi' ) {
1898 $output .= '</i>';
1899 $state = 'b';
1900 } elseif ( $state === 'ib' ) {
1901 $output .= '</b></i><b>';
1902 $state = 'b';
1903 } elseif ( $state === 'both' ) {
1904 $output .= '<b><i>' . $buffer . '</i>';
1905 $state = 'b';
1906 } else { // $state can be 'b' or ''
1907 $output .= '<i>';
1908 $state .= 'i';
1909 }
1910 } elseif ( $thislen == 3 ) {
1911 if ( $state === 'b' ) {
1912 $output .= '</b>';
1913 $state = '';
1914 } elseif ( $state === 'bi' ) {
1915 $output .= '</i></b><i>';
1916 $state = 'i';
1917 } elseif ( $state === 'ib' ) {
1918 $output .= '</b>';
1919 $state = 'i';
1920 } elseif ( $state === 'both' ) {
1921 $output .= '<i><b>' . $buffer . '</b>';
1922 $state = 'i';
1923 } else { // $state can be 'i' or ''
1924 $output .= '<b>';
1925 $state .= 'b';
1926 }
1927 } elseif ( $thislen == 5 ) {
1928 if ( $state === 'b' ) {
1929 $output .= '</b><i>';
1930 $state = 'i';
1931 } elseif ( $state === 'i' ) {
1932 $output .= '</i><b>';
1933 $state = 'b';
1934 } elseif ( $state === 'bi' ) {
1935 $output .= '</i></b>';
1936 $state = '';
1937 } elseif ( $state === 'ib' ) {
1938 $output .= '</b></i>';
1939 $state = '';
1940 } elseif ( $state === 'both' ) {
1941 $output .= '<i><b>' . $buffer . '</b></i>';
1942 $state = '';
1943 } else { // ($state == '')
1944 $buffer = '';
1945 $state = 'both';
1946 }
1947 }
1948 }
1949 $i++;
1950 }
1951 // Now close all remaining tags. Notice that the order is important.
1952 if ( $state === 'b' || $state === 'ib' ) {
1953 $output .= '</b>';
1954 }
1955 if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
1956 $output .= '</i>';
1957 }
1958 if ( $state === 'bi' ) {
1959 $output .= '</b>';
1960 }
1961 // There might be lonely ''''', so make sure we have a buffer
1962 if ( $state === 'both' && $buffer ) {
1963 $output .= '<b><i>' . $buffer . '</i></b>';
1964 }
1965 return $output;
1966 }
1967
1968 /**
1969 * Replace external links (REL)
1970 *
1971 * Note: this is all very hackish and the order of execution matters a lot.
1972 * Make sure to run tests/parser/parserTests.php if you change this code.
1973 *
1974 * @private
1975 *
1976 * @param string $text
1977 *
1978 * @throws MWException
1979 * @return string
1980 */
1981 public function replaceExternalLinks( $text ) {
1982 $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1983 if ( $bits === false ) {
1984 throw new MWException( "PCRE needs to be compiled with "
1985 . "--enable-unicode-properties in order for MediaWiki to function" );
1986 }
1987 $s = array_shift( $bits );
1988
1989 $i = 0;
1990 while ( $i < count( $bits ) ) {
1991 $url = $bits[$i++];
1992 $i++; // protocol
1993 $text = $bits[$i++];
1994 $trail = $bits[$i++];
1995
1996 # The characters '<' and '>' (which were escaped by
1997 # removeHTMLtags()) should not be included in
1998 # URLs, per RFC 2396.
1999 $m2 = [];
2000 if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
2001 $text = substr( $url, $m2[0][1] ) . ' ' . $text;
2002 $url = substr( $url, 0, $m2[0][1] );
2003 }
2004
2005 # If the link text is an image URL, replace it with an <img> tag
2006 # This happened by accident in the original parser, but some people used it extensively
2007 $img = $this->maybeMakeExternalImage( $text );
2008 if ( $img !== false ) {
2009 $text = $img;
2010 }
2011
2012 $dtrail = '';
2013
2014 # Set linktype for CSS
2015 $linktype = 'text';
2016
2017 # No link text, e.g. [http://domain.tld/some.link]
2018 if ( $text == '' ) {
2019 # Autonumber
2020 $langObj = $this->getTargetLanguage();
2021 $text = '[' . $langObj->formatNum( ++$this->mAutonumber ) . ']';
2022 $linktype = 'autonumber';
2023 } else {
2024 # Have link text, e.g. [http://domain.tld/some.link text]s
2025 # Check for trail
2026 list( $dtrail, $trail ) = Linker::splitTrail( $trail );
2027 }
2028
2029 // Excluding protocol-relative URLs may avoid many false positives.
2030 if ( preg_match( '/^(?:' . wfUrlProtocolsWithoutProtRel() . ')/', $text ) ) {
2031 $text = $this->getTargetLanguage()->getConverter()->markNoConversion( $text );
2032 }
2033
2034 $url = Sanitizer::cleanUrl( $url );
2035
2036 # Use the encoded URL
2037 # This means that users can paste URLs directly into the text
2038 # Funny characters like ö aren't valid in URLs anyway
2039 # This was changed in August 2004
2040 $s .= Linker::makeExternalLink( $url, $text, false, $linktype,
2041 $this->getExternalLinkAttribs( $url ), $this->mTitle ) . $dtrail . $trail;
2042
2043 # Register link in the output object.
2044 $this->mOutput->addExternalLink( $url );
2045 }
2046
2047 return $s;
2048 }
2049
2050 /**
2051 * Get the rel attribute for a particular external link.
2052 *
2053 * @since 1.21
2054 * @param string|bool $url Optional URL, to extract the domain from for rel =>
2055 * nofollow if appropriate
2056 * @param LinkTarget|null $title Optional LinkTarget, for wgNoFollowNsExceptions lookups
2057 * @return string|null Rel attribute for $url
2058 */
2059 public static function getExternalLinkRel( $url = false, $title = null ) {
2060 global $wgNoFollowLinks, $wgNoFollowNsExceptions, $wgNoFollowDomainExceptions;
2061 $ns = $title ? $title->getNamespace() : false;
2062 if ( $wgNoFollowLinks && !in_array( $ns, $wgNoFollowNsExceptions )
2063 && !wfMatchesDomainList( $url, $wgNoFollowDomainExceptions )
2064 ) {
2065 return 'nofollow';
2066 }
2067 return null;
2068 }
2069
2070 /**
2071 * Get an associative array of additional HTML attributes appropriate for a
2072 * particular external link. This currently may include rel => nofollow
2073 * (depending on configuration, namespace, and the URL's domain) and/or a
2074 * target attribute (depending on configuration).
2075 *
2076 * @param string $url URL to extract the domain from for rel =>
2077 * nofollow if appropriate
2078 * @return array Associative array of HTML attributes
2079 */
2080 public function getExternalLinkAttribs( $url ) {
2081 $attribs = [];
2082 $rel = self::getExternalLinkRel( $url, $this->mTitle );
2083
2084 $target = $this->mOptions->getExternalLinkTarget();
2085 if ( $target ) {
2086 $attribs['target'] = $target;
2087 if ( !in_array( $target, [ '_self', '_parent', '_top' ] ) ) {
2088 // T133507. New windows can navigate parent cross-origin.
2089 // Including noreferrer due to lacking browser
2090 // support of noopener. Eventually noreferrer should be removed.
2091 if ( $rel !== '' ) {
2092 $rel .= ' ';
2093 }
2094 $rel .= 'noreferrer noopener';
2095 }
2096 }
2097 $attribs['rel'] = $rel;
2098 return $attribs;
2099 }
2100
2101 /**
2102 * Replace unusual escape codes in a URL with their equivalent characters
2103 *
2104 * This generally follows the syntax defined in RFC 3986, with special
2105 * consideration for HTTP query strings.
2106 *
2107 * @param string $url
2108 * @return string
2109 */
2110 public static function normalizeLinkUrl( $url ) {
2111 # Test for RFC 3986 IPv6 syntax
2112 $scheme = '[a-z][a-z0-9+.-]*:';
2113 $userinfo = '(?:[a-z0-9\-._~!$&\'()*+,;=:]|%[0-9a-f]{2})*';
2114 $ipv6Host = '\\[((?:[0-9a-f:]|%3[0-A]|%[46][1-6])+)\\]';
2115 if ( preg_match( "<^(?:{$scheme})?//(?:{$userinfo}@)?{$ipv6Host}(?:[:/?#].*|)$>i", $url, $m ) &&
2116 IP::isValid( rawurldecode( $m[1] ) )
2117 ) {
2118 $isIPv6 = rawurldecode( $m[1] );
2119 } else {
2120 $isIPv6 = false;
2121 }
2122
2123 # Make sure unsafe characters are encoded
2124 $url = preg_replace_callback( '/[\x00-\x20"<>\[\\\\\]^`{|}\x7F-\xFF]/',
2125 function ( $m ) {
2126 return rawurlencode( $m[0] );
2127 },
2128 $url
2129 );
2130
2131 $ret = '';
2132 $end = strlen( $url );
2133
2134 # Fragment part - 'fragment'
2135 $start = strpos( $url, '#' );
2136 if ( $start !== false && $start < $end ) {
2137 $ret = self::normalizeUrlComponent(
2138 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}' ) . $ret;
2139 $end = $start;
2140 }
2141
2142 # Query part - 'query' minus &=+;
2143 $start = strpos( $url, '?' );
2144 if ( $start !== false && $start < $end ) {
2145 $ret = self::normalizeUrlComponent(
2146 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}&=+;' ) . $ret;
2147 $end = $start;
2148 }
2149
2150 # Scheme and path part - 'pchar'
2151 # (we assume no userinfo or encoded colons in the host)
2152 $ret = self::normalizeUrlComponent(
2153 substr( $url, 0, $end ), '"#%<>[\]^`{|}/?' ) . $ret;
2154
2155 # Fix IPv6 syntax
2156 if ( $isIPv6 !== false ) {
2157 $ipv6Host = "%5B({$isIPv6})%5D";
2158 $ret = preg_replace(
2159 "<^((?:{$scheme})?//(?:{$userinfo}@)?){$ipv6Host}(?=[:/?#]|$)>i",
2160 "$1[$2]",
2161 $ret
2162 );
2163 }
2164
2165 return $ret;
2166 }
2167
2168 private static function normalizeUrlComponent( $component, $unsafe ) {
2169 $callback = function ( $matches ) use ( $unsafe ) {
2170 $char = urldecode( $matches[0] );
2171 $ord = ord( $char );
2172 if ( $ord > 32 && $ord < 127 && strpos( $unsafe, $char ) === false ) {
2173 # Unescape it
2174 return $char;
2175 } else {
2176 # Leave it escaped, but use uppercase for a-f
2177 return strtoupper( $matches[0] );
2178 }
2179 };
2180 return preg_replace_callback( '/%[0-9A-Fa-f]{2}/', $callback, $component );
2181 }
2182
2183 /**
2184 * make an image if it's allowed, either through the global
2185 * option, through the exception, or through the on-wiki whitelist
2186 *
2187 * @param string $url
2188 *
2189 * @return string
2190 */
2191 private function maybeMakeExternalImage( $url ) {
2192 $imagesfrom = $this->mOptions->getAllowExternalImagesFrom();
2193 $imagesexception = !empty( $imagesfrom );
2194 $text = false;
2195 # $imagesfrom could be either a single string or an array of strings, parse out the latter
2196 if ( $imagesexception && is_array( $imagesfrom ) ) {
2197 $imagematch = false;
2198 foreach ( $imagesfrom as $match ) {
2199 if ( strpos( $url, $match ) === 0 ) {
2200 $imagematch = true;
2201 break;
2202 }
2203 }
2204 } elseif ( $imagesexception ) {
2205 $imagematch = ( strpos( $url, $imagesfrom ) === 0 );
2206 } else {
2207 $imagematch = false;
2208 }
2209
2210 if ( $this->mOptions->getAllowExternalImages()
2211 || ( $imagesexception && $imagematch )
2212 ) {
2213 if ( preg_match( self::EXT_IMAGE_REGEX, $url ) ) {
2214 # Image found
2215 $text = Linker::makeExternalImage( $url );
2216 }
2217 }
2218 if ( !$text && $this->mOptions->getEnableImageWhitelist()
2219 && preg_match( self::EXT_IMAGE_REGEX, $url )
2220 ) {
2221 $whitelist = explode(
2222 "\n",
2223 wfMessage( 'external_image_whitelist' )->inContentLanguage()->text()
2224 );
2225
2226 foreach ( $whitelist as $entry ) {
2227 # Sanitize the regex fragment, make it case-insensitive, ignore blank entries/comments
2228 if ( strpos( $entry, '#' ) === 0 || $entry === '' ) {
2229 continue;
2230 }
2231 if ( preg_match( '/' . str_replace( '/', '\\/', $entry ) . '/i', $url ) ) {
2232 # Image matches a whitelist entry
2233 $text = Linker::makeExternalImage( $url );
2234 break;
2235 }
2236 }
2237 }
2238 return $text;
2239 }
2240
2241 /**
2242 * Process [[ ]] wikilinks
2243 *
2244 * @param string $s
2245 *
2246 * @return string Processed text
2247 *
2248 * @private
2249 */
2250 public function replaceInternalLinks( $s ) {
2251 $this->mLinkHolders->merge( $this->replaceInternalLinks2( $s ) );
2252 return $s;
2253 }
2254
2255 /**
2256 * Process [[ ]] wikilinks (RIL)
2257 * @param string &$s
2258 * @throws MWException
2259 * @return LinkHolderArray
2260 *
2261 * @private
2262 */
2263 public function replaceInternalLinks2( &$s ) {
2264 static $tc = false, $e1, $e1_img;
2265 # the % is needed to support urlencoded titles as well
2266 if ( !$tc ) {
2267 $tc = Title::legalChars() . '#%';
2268 # Match a link having the form [[namespace:link|alternate]]trail
2269 $e1 = "/^([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
2270 # Match cases where there is no "]]", which might still be images
2271 $e1_img = "/^([{$tc}]+)\\|(.*)\$/sD";
2272 }
2273
2274 $holders = new LinkHolderArray( $this );
2275
2276 # split the entire text string on occurrences of [[
2277 $a = StringUtils::explode( '[[', ' ' . $s );
2278 # get the first element (all text up to first [[), and remove the space we added
2279 $s = $a->current();
2280 $a->next();
2281 $line = $a->current(); # Workaround for broken ArrayIterator::next() that returns "void"
2282 $s = substr( $s, 1 );
2283
2284 $useLinkPrefixExtension = $this->getTargetLanguage()->linkPrefixExtension();
2285 $e2 = null;
2286 if ( $useLinkPrefixExtension ) {
2287 # Match the end of a line for a word that's not followed by whitespace,
2288 # e.g. in the case of 'The Arab al[[Razi]]', 'al' will be matched
2289 $charset = $this->contLang->linkPrefixCharset();
2290 $e2 = "/^((?>.*[^$charset]|))(.+)$/sDu";
2291 }
2292
2293 if ( is_null( $this->mTitle ) ) {
2294 throw new MWException( __METHOD__ . ": \$this->mTitle is null\n" );
2295 }
2296 $nottalk = !$this->mTitle->isTalkPage();
2297
2298 if ( $useLinkPrefixExtension ) {
2299 $m = [];
2300 if ( preg_match( $e2, $s, $m ) ) {
2301 $first_prefix = $m[2];
2302 } else {
2303 $first_prefix = false;
2304 }
2305 } else {
2306 $prefix = '';
2307 }
2308
2309 $useSubpages = $this->areSubpagesAllowed();
2310
2311 # Loop for each link
2312 for ( ; $line !== false && $line !== null; $a->next(), $line = $a->current() ) {
2313 # Check for excessive memory usage
2314 if ( $holders->isBig() ) {
2315 # Too big
2316 # Do the existence check, replace the link holders and clear the array
2317 $holders->replace( $s );
2318 $holders->clear();
2319 }
2320
2321 if ( $useLinkPrefixExtension ) {
2322 if ( preg_match( $e2, $s, $m ) ) {
2323 list( , $s, $prefix ) = $m;
2324 } else {
2325 $prefix = '';
2326 }
2327 # first link
2328 if ( $first_prefix ) {
2329 $prefix = $first_prefix;
2330 $first_prefix = false;
2331 }
2332 }
2333
2334 $might_be_img = false;
2335
2336 if ( preg_match( $e1, $line, $m ) ) { # page with normal text or alt
2337 $text = $m[2];
2338 # If we get a ] at the beginning of $m[3] that means we have a link that's something like:
2339 # [[Image:Foo.jpg|[http://example.com desc]]] <- having three ] in a row fucks up,
2340 # the real problem is with the $e1 regex
2341 # See T1500.
2342 # Still some problems for cases where the ] is meant to be outside punctuation,
2343 # and no image is in sight. See T4095.
2344 if ( $text !== ''
2345 && substr( $m[3], 0, 1 ) === ']'
2346 && strpos( $text, '[' ) !== false
2347 ) {
2348 $text .= ']'; # so that replaceExternalLinks($text) works later
2349 $m[3] = substr( $m[3], 1 );
2350 }
2351 # fix up urlencoded title texts
2352 if ( strpos( $m[1], '%' ) !== false ) {
2353 # Should anchors '#' also be rejected?
2354 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2355 }
2356 $trail = $m[3];
2357 } elseif ( preg_match( $e1_img, $line, $m ) ) {
2358 # Invalid, but might be an image with a link in its caption
2359 $might_be_img = true;
2360 $text = $m[2];
2361 if ( strpos( $m[1], '%' ) !== false ) {
2362 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2363 }
2364 $trail = "";
2365 } else { # Invalid form; output directly
2366 $s .= $prefix . '[[' . $line;
2367 continue;
2368 }
2369
2370 $origLink = ltrim( $m[1], ' ' );
2371
2372 # Don't allow internal links to pages containing
2373 # PROTO: where PROTO is a valid URL protocol; these
2374 # should be external links.
2375 if ( preg_match( '/^(?i:' . $this->mUrlProtocols . ')/', $origLink ) ) {
2376 $s .= $prefix . '[[' . $line;
2377 continue;
2378 }
2379
2380 # Make subpage if necessary
2381 if ( $useSubpages ) {
2382 $link = $this->maybeDoSubpageLink( $origLink, $text );
2383 } else {
2384 $link = $origLink;
2385 }
2386
2387 // \x7f isn't a default legal title char, so most likely strip
2388 // markers will force us into the "invalid form" path above. But,
2389 // just in case, let's assert that xmlish tags aren't valid in
2390 // the title position.
2391 $unstrip = $this->mStripState->killMarkers( $link );
2392 $noMarkers = ( $unstrip === $link );
2393
2394 $nt = $noMarkers ? Title::newFromText( $link ) : null;
2395 if ( $nt === null ) {
2396 $s .= $prefix . '[[' . $line;
2397 continue;
2398 }
2399
2400 $ns = $nt->getNamespace();
2401 $iw = $nt->getInterwiki();
2402
2403 $noforce = ( substr( $origLink, 0, 1 ) !== ':' );
2404
2405 if ( $might_be_img ) { # if this is actually an invalid link
2406 if ( $ns == NS_FILE && $noforce ) { # but might be an image
2407 $found = false;
2408 while ( true ) {
2409 # look at the next 'line' to see if we can close it there
2410 $a->next();
2411 $next_line = $a->current();
2412 if ( $next_line === false || $next_line === null ) {
2413 break;
2414 }
2415 $m = explode( ']]', $next_line, 3 );
2416 if ( count( $m ) == 3 ) {
2417 # the first ]] closes the inner link, the second the image
2418 $found = true;
2419 $text .= "[[{$m[0]}]]{$m[1]}";
2420 $trail = $m[2];
2421 break;
2422 } elseif ( count( $m ) == 2 ) {
2423 # if there's exactly one ]] that's fine, we'll keep looking
2424 $text .= "[[{$m[0]}]]{$m[1]}";
2425 } else {
2426 # if $next_line is invalid too, we need look no further
2427 $text .= '[[' . $next_line;
2428 break;
2429 }
2430 }
2431 if ( !$found ) {
2432 # we couldn't find the end of this imageLink, so output it raw
2433 # but don't ignore what might be perfectly normal links in the text we've examined
2434 $holders->merge( $this->replaceInternalLinks2( $text ) );
2435 $s .= "{$prefix}[[$link|$text";
2436 # note: no $trail, because without an end, there *is* no trail
2437 continue;
2438 }
2439 } else { # it's not an image, so output it raw
2440 $s .= "{$prefix}[[$link|$text";
2441 # note: no $trail, because without an end, there *is* no trail
2442 continue;
2443 }
2444 }
2445
2446 $wasblank = ( $text == '' );
2447 if ( $wasblank ) {
2448 $text = $link;
2449 if ( !$noforce ) {
2450 # Strip off leading ':'
2451 $text = substr( $text, 1 );
2452 }
2453 } else {
2454 # T6598 madness. Handle the quotes only if they come from the alternate part
2455 # [[Lista d''e paise d''o munno]] -> <a href="...">Lista d''e paise d''o munno</a>
2456 # [[Criticism of Harry Potter|Criticism of ''Harry Potter'']]
2457 # -> <a href="Criticism of Harry Potter">Criticism of <i>Harry Potter</i></a>
2458 $text = $this->doQuotes( $text );
2459 }
2460
2461 # Link not escaped by : , create the various objects
2462 if ( $noforce && !$nt->wasLocalInterwiki() ) {
2463 # Interwikis
2464 if (
2465 $iw && $this->mOptions->getInterwikiMagic() && $nottalk && (
2466 Language::fetchLanguageName( $iw, null, 'mw' ) ||
2467 in_array( $iw, $this->svcOptions->get( 'ExtraInterlanguageLinkPrefixes' ) )
2468 )
2469 ) {
2470 # T26502: filter duplicates
2471 if ( !isset( $this->mLangLinkLanguages[$iw] ) ) {
2472 $this->mLangLinkLanguages[$iw] = true;
2473 $this->mOutput->addLanguageLink( $nt->getFullText() );
2474 }
2475
2476 /**
2477 * Strip the whitespace interwiki links produce, see T10897
2478 */
2479 $s = rtrim( $s . $prefix ) . $trail; # T175416
2480 continue;
2481 }
2482
2483 if ( $ns == NS_FILE ) {
2484 if ( !wfIsBadImage( $nt->getDBkey(), $this->mTitle ) ) {
2485 if ( $wasblank ) {
2486 # if no parameters were passed, $text
2487 # becomes something like "File:Foo.png",
2488 # which we don't want to pass on to the
2489 # image generator
2490 $text = '';
2491 } else {
2492 # recursively parse links inside the image caption
2493 # actually, this will parse them in any other parameters, too,
2494 # but it might be hard to fix that, and it doesn't matter ATM
2495 $text = $this->replaceExternalLinks( $text );
2496 $holders->merge( $this->replaceInternalLinks2( $text ) );
2497 }
2498 # cloak any absolute URLs inside the image markup, so replaceExternalLinks() won't touch them
2499 $s .= $prefix . $this->armorLinks(
2500 $this->makeImage( $nt, $text, $holders ) ) . $trail;
2501 continue;
2502 }
2503 } elseif ( $ns == NS_CATEGORY ) {
2504 /**
2505 * Strip the whitespace Category links produce, see T2087
2506 */
2507 $s = rtrim( $s . $prefix ) . $trail; # T2087, T87753
2508
2509 if ( $wasblank ) {
2510 $sortkey = $this->getDefaultSort();
2511 } else {
2512 $sortkey = $text;
2513 }
2514 $sortkey = Sanitizer::decodeCharReferences( $sortkey );
2515 $sortkey = str_replace( "\n", '', $sortkey );
2516 $sortkey = $this->getTargetLanguage()->convertCategoryKey( $sortkey );
2517 $this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
2518
2519 continue;
2520 }
2521 }
2522
2523 # Self-link checking. For some languages, variants of the title are checked in
2524 # LinkHolderArray::doVariants() to allow batching the existence checks necessary
2525 # for linking to a different variant.
2526 if ( $ns != NS_SPECIAL && $nt->equals( $this->mTitle ) && !$nt->hasFragment() ) {
2527 $s .= $prefix . Linker::makeSelfLinkObj( $nt, $text, '', $trail );
2528 continue;
2529 }
2530
2531 # NS_MEDIA is a pseudo-namespace for linking directly to a file
2532 # @todo FIXME: Should do batch file existence checks, see comment below
2533 if ( $ns == NS_MEDIA ) {
2534 # Give extensions a chance to select the file revision for us
2535 $options = [];
2536 $descQuery = false;
2537 Hooks::run( 'BeforeParserFetchFileAndTitle',
2538 [ $this, $nt, &$options, &$descQuery ] );
2539 # Fetch and register the file (file title may be different via hooks)
2540 list( $file, $nt ) = $this->fetchFileAndTitle( $nt, $options );
2541 # Cloak with NOPARSE to avoid replacement in replaceExternalLinks
2542 $s .= $prefix . $this->armorLinks(
2543 Linker::makeMediaLinkFile( $nt, $file, $text ) ) . $trail;
2544 continue;
2545 }
2546
2547 # Some titles, such as valid special pages or files in foreign repos, should
2548 # be shown as bluelinks even though they're not included in the page table
2549 # @todo FIXME: isAlwaysKnown() can be expensive for file links; we should really do
2550 # batch file existence checks for NS_FILE and NS_MEDIA
2551 if ( $iw == '' && $nt->isAlwaysKnown() ) {
2552 $this->mOutput->addLink( $nt );
2553 $s .= $this->makeKnownLinkHolder( $nt, $text, $trail, $prefix );
2554 } else {
2555 # Links will be added to the output link list after checking
2556 $s .= $holders->makeHolder( $nt, $text, [], $trail, $prefix );
2557 }
2558 }
2559 return $holders;
2560 }
2561
2562 /**
2563 * Render a forced-blue link inline; protect against double expansion of
2564 * URLs if we're in a mode that prepends full URL prefixes to internal links.
2565 * Since this little disaster has to split off the trail text to avoid
2566 * breaking URLs in the following text without breaking trails on the
2567 * wiki links, it's been made into a horrible function.
2568 *
2569 * @param Title $nt
2570 * @param string $text
2571 * @param string $trail
2572 * @param string $prefix
2573 * @return string HTML-wikitext mix oh yuck
2574 */
2575 protected function makeKnownLinkHolder( $nt, $text = '', $trail = '', $prefix = '' ) {
2576 list( $inside, $trail ) = Linker::splitTrail( $trail );
2577
2578 if ( $text == '' ) {
2579 $text = htmlspecialchars( $nt->getPrefixedText() );
2580 }
2581
2582 $link = $this->getLinkRenderer()->makeKnownLink(
2583 $nt, new HtmlArmor( "$prefix$text$inside" )
2584 );
2585
2586 return $this->armorLinks( $link ) . $trail;
2587 }
2588
2589 /**
2590 * Insert a NOPARSE hacky thing into any inline links in a chunk that's
2591 * going to go through further parsing steps before inline URL expansion.
2592 *
2593 * Not needed quite as much as it used to be since free links are a bit
2594 * more sensible these days. But bracketed links are still an issue.
2595 *
2596 * @param string $text More-or-less HTML
2597 * @return string Less-or-more HTML with NOPARSE bits
2598 */
2599 public function armorLinks( $text ) {
2600 return preg_replace( '/\b((?i)' . $this->mUrlProtocols . ')/',
2601 self::MARKER_PREFIX . "NOPARSE$1", $text );
2602 }
2603
2604 /**
2605 * Return true if subpage links should be expanded on this page.
2606 * @return bool
2607 */
2608 public function areSubpagesAllowed() {
2609 # Some namespaces don't allow subpages
2610 return $this->nsInfo->hasSubpages( $this->mTitle->getNamespace() );
2611 }
2612
2613 /**
2614 * Handle link to subpage if necessary
2615 *
2616 * @param string $target The source of the link
2617 * @param string &$text The link text, modified as necessary
2618 * @return string The full name of the link
2619 * @private
2620 */
2621 public function maybeDoSubpageLink( $target, &$text ) {
2622 return Linker::normalizeSubpageLink( $this->mTitle, $target, $text );
2623 }
2624
2625 /**
2626 * Make lists from lines starting with ':', '*', '#', etc. (DBL)
2627 *
2628 * @param string $text
2629 * @param bool $linestart Whether or not this is at the start of a line.
2630 * @private
2631 * @return string The lists rendered as HTML
2632 */
2633 public function doBlockLevels( $text, $linestart ) {
2634 return BlockLevelPass::doBlockLevels( $text, $linestart );
2635 }
2636
2637 /**
2638 * Return value of a magic variable (like PAGENAME)
2639 *
2640 * @private
2641 *
2642 * @param string $index Magic variable identifier as mapped in MagicWordFactory::$mVariableIDs
2643 * @param bool|PPFrame $frame
2644 *
2645 * @throws MWException
2646 * @return string
2647 */
2648 public function getVariableValue( $index, $frame = false ) {
2649 if ( is_null( $this->mTitle ) ) {
2650 // If no title set, bad things are going to happen
2651 // later. Title should always be set since this
2652 // should only be called in the middle of a parse
2653 // operation (but the unit-tests do funky stuff)
2654 throw new MWException( __METHOD__ . ' Should only be '
2655 . ' called while parsing (no title set)' );
2656 }
2657
2658 // Avoid PHP 7.1 warning from passing $this by reference
2659 $parser = $this;
2660
2661 /**
2662 * Some of these require message or data lookups and can be
2663 * expensive to check many times.
2664 */
2665 if (
2666 Hooks::run( 'ParserGetVariableValueVarCache', [ &$parser, &$this->mVarCache ] ) &&
2667 isset( $this->mVarCache[$index] )
2668 ) {
2669 return $this->mVarCache[$index];
2670 }
2671
2672 $ts = wfTimestamp( TS_UNIX, $this->mOptions->getTimestamp() );
2673 Hooks::run( 'ParserGetVariableValueTs', [ &$parser, &$ts ] );
2674
2675 $pageLang = $this->getFunctionLang();
2676
2677 switch ( $index ) {
2678 case '!':
2679 $value = '|';
2680 break;
2681 case 'currentmonth':
2682 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'm' ), true );
2683 break;
2684 case 'currentmonth1':
2685 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'n' ), true );
2686 break;
2687 case 'currentmonthname':
2688 $value = $pageLang->getMonthName( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2689 break;
2690 case 'currentmonthnamegen':
2691 $value = $pageLang->getMonthNameGen( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2692 break;
2693 case 'currentmonthabbrev':
2694 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2695 break;
2696 case 'currentday':
2697 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'j' ), true );
2698 break;
2699 case 'currentday2':
2700 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'd' ), true );
2701 break;
2702 case 'localmonth':
2703 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'm' ), true );
2704 break;
2705 case 'localmonth1':
2706 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'n' ), true );
2707 break;
2708 case 'localmonthname':
2709 $value = $pageLang->getMonthName( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2710 break;
2711 case 'localmonthnamegen':
2712 $value = $pageLang->getMonthNameGen( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2713 break;
2714 case 'localmonthabbrev':
2715 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2716 break;
2717 case 'localday':
2718 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'j' ), true );
2719 break;
2720 case 'localday2':
2721 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'd' ), true );
2722 break;
2723 case 'pagename':
2724 $value = wfEscapeWikiText( $this->mTitle->getText() );
2725 break;
2726 case 'pagenamee':
2727 $value = wfEscapeWikiText( $this->mTitle->getPartialURL() );
2728 break;
2729 case 'fullpagename':
2730 $value = wfEscapeWikiText( $this->mTitle->getPrefixedText() );
2731 break;
2732 case 'fullpagenamee':
2733 $value = wfEscapeWikiText( $this->mTitle->getPrefixedURL() );
2734 break;
2735 case 'subpagename':
2736 $value = wfEscapeWikiText( $this->mTitle->getSubpageText() );
2737 break;
2738 case 'subpagenamee':
2739 $value = wfEscapeWikiText( $this->mTitle->getSubpageUrlForm() );
2740 break;
2741 case 'rootpagename':
2742 $value = wfEscapeWikiText( $this->mTitle->getRootText() );
2743 break;
2744 case 'rootpagenamee':
2745 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2746 ' ',
2747 '_',
2748 $this->mTitle->getRootText()
2749 ) ) );
2750 break;
2751 case 'basepagename':
2752 $value = wfEscapeWikiText( $this->mTitle->getBaseText() );
2753 break;
2754 case 'basepagenamee':
2755 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2756 ' ',
2757 '_',
2758 $this->mTitle->getBaseText()
2759 ) ) );
2760 break;
2761 case 'talkpagename':
2762 if ( $this->mTitle->canHaveTalkPage() ) {
2763 $talkPage = $this->mTitle->getTalkPage();
2764 $value = wfEscapeWikiText( $talkPage->getPrefixedText() );
2765 } else {
2766 $value = '';
2767 }
2768 break;
2769 case 'talkpagenamee':
2770 if ( $this->mTitle->canHaveTalkPage() ) {
2771 $talkPage = $this->mTitle->getTalkPage();
2772 $value = wfEscapeWikiText( $talkPage->getPrefixedURL() );
2773 } else {
2774 $value = '';
2775 }
2776 break;
2777 case 'subjectpagename':
2778 $subjPage = $this->mTitle->getSubjectPage();
2779 $value = wfEscapeWikiText( $subjPage->getPrefixedText() );
2780 break;
2781 case 'subjectpagenamee':
2782 $subjPage = $this->mTitle->getSubjectPage();
2783 $value = wfEscapeWikiText( $subjPage->getPrefixedURL() );
2784 break;
2785 case 'pageid': // requested in T25427
2786 # Inform the edit saving system that getting the canonical output
2787 # after page insertion requires a parse that used that exact page ID
2788 $this->setOutputFlag( 'vary-page-id', '{{PAGEID}} used' );
2789 $value = $this->mTitle->getArticleID();
2790 if ( !$value ) {
2791 $value = $this->mOptions->getSpeculativePageId();
2792 if ( $value ) {
2793 $this->mOutput->setSpeculativePageIdUsed( $value );
2794 }
2795 }
2796 break;
2797 case 'revisionid':
2798 if (
2799 $this->svcOptions->get( 'MiserMode' ) &&
2800 !$this->mOptions->getInterfaceMessage() &&
2801 // @TODO: disallow this word on all namespaces
2802 $this->nsInfo->isContent( $this->mTitle->getNamespace() )
2803 ) {
2804 // Use a stub result instead of the actual revision ID in order to avoid
2805 // double parses on page save but still allow preview detection (T137900)
2806 if ( $this->getRevisionId() || $this->mOptions->getSpeculativeRevId() ) {
2807 $value = '-';
2808 } else {
2809 $this->setOutputFlag( 'vary-revision-exists', '{{REVISIONID}} used' );
2810 $value = '';
2811 }
2812 } else {
2813 # Inform the edit saving system that getting the canonical output after
2814 # revision insertion requires a parse that used that exact revision ID
2815 $this->setOutputFlag( 'vary-revision-id', '{{REVISIONID}} used' );
2816 $value = $this->getRevisionId();
2817 if ( $value === 0 ) {
2818 $rev = $this->getRevisionObject();
2819 $value = $rev ? $rev->getId() : $value;
2820 }
2821 if ( !$value ) {
2822 $value = $this->mOptions->getSpeculativeRevId();
2823 if ( $value ) {
2824 $this->mOutput->setSpeculativeRevIdUsed( $value );
2825 }
2826 }
2827 }
2828 break;
2829 case 'revisionday':
2830 $value = (int)$this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2831 break;
2832 case 'revisionday2':
2833 $value = $this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2834 break;
2835 case 'revisionmonth':
2836 $value = $this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2837 break;
2838 case 'revisionmonth1':
2839 $value = (int)$this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2840 break;
2841 case 'revisionyear':
2842 $value = $this->getRevisionTimestampSubstring( 0, 4, self::MAX_TTS, $index );
2843 break;
2844 case 'revisiontimestamp':
2845 $value = $this->getRevisionTimestampSubstring( 0, 14, self::MAX_TTS, $index );
2846 break;
2847 case 'revisionuser':
2848 # Inform the edit saving system that getting the canonical output after
2849 # revision insertion requires a parse that used the actual user ID
2850 $this->setOutputFlag( 'vary-user', '{{REVISIONUSER}} used' );
2851 $value = $this->getRevisionUser();
2852 break;
2853 case 'revisionsize':
2854 $value = $this->getRevisionSize();
2855 break;
2856 case 'namespace':
2857 $value = str_replace( '_', ' ',
2858 $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2859 break;
2860 case 'namespacee':
2861 $value = wfUrlencode( $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2862 break;
2863 case 'namespacenumber':
2864 $value = $this->mTitle->getNamespace();
2865 break;
2866 case 'talkspace':
2867 $value = $this->mTitle->canHaveTalkPage()
2868 ? str_replace( '_', ' ', $this->mTitle->getTalkNsText() )
2869 : '';
2870 break;
2871 case 'talkspacee':
2872 $value = $this->mTitle->canHaveTalkPage() ? wfUrlencode( $this->mTitle->getTalkNsText() ) : '';
2873 break;
2874 case 'subjectspace':
2875 $value = str_replace( '_', ' ', $this->mTitle->getSubjectNsText() );
2876 break;
2877 case 'subjectspacee':
2878 $value = ( wfUrlencode( $this->mTitle->getSubjectNsText() ) );
2879 break;
2880 case 'currentdayname':
2881 $value = $pageLang->getWeekdayName( (int)MWTimestamp::getInstance( $ts )->format( 'w' ) + 1 );
2882 break;
2883 case 'currentyear':
2884 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'Y' ), true );
2885 break;
2886 case 'currenttime':
2887 $value = $pageLang->time( wfTimestamp( TS_MW, $ts ), false, false );
2888 break;
2889 case 'currenthour':
2890 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'H' ), true );
2891 break;
2892 case 'currentweek':
2893 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2894 # int to remove the padding
2895 $value = $pageLang->formatNum( (int)MWTimestamp::getInstance( $ts )->format( 'W' ) );
2896 break;
2897 case 'currentdow':
2898 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'w' ) );
2899 break;
2900 case 'localdayname':
2901 $value = $pageLang->getWeekdayName(
2902 (int)MWTimestamp::getLocalInstance( $ts )->format( 'w' ) + 1
2903 );
2904 break;
2905 case 'localyear':
2906 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'Y' ), true );
2907 break;
2908 case 'localtime':
2909 $value = $pageLang->time(
2910 MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' ),
2911 false,
2912 false
2913 );
2914 break;
2915 case 'localhour':
2916 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'H' ), true );
2917 break;
2918 case 'localweek':
2919 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2920 # int to remove the padding
2921 $value = $pageLang->formatNum( (int)MWTimestamp::getLocalInstance( $ts )->format( 'W' ) );
2922 break;
2923 case 'localdow':
2924 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'w' ) );
2925 break;
2926 case 'numberofarticles':
2927 $value = $pageLang->formatNum( SiteStats::articles() );
2928 break;
2929 case 'numberoffiles':
2930 $value = $pageLang->formatNum( SiteStats::images() );
2931 break;
2932 case 'numberofusers':
2933 $value = $pageLang->formatNum( SiteStats::users() );
2934 break;
2935 case 'numberofactiveusers':
2936 $value = $pageLang->formatNum( SiteStats::activeUsers() );
2937 break;
2938 case 'numberofpages':
2939 $value = $pageLang->formatNum( SiteStats::pages() );
2940 break;
2941 case 'numberofadmins':
2942 $value = $pageLang->formatNum( SiteStats::numberingroup( 'sysop' ) );
2943 break;
2944 case 'numberofedits':
2945 $value = $pageLang->formatNum( SiteStats::edits() );
2946 break;
2947 case 'currenttimestamp':
2948 $value = wfTimestamp( TS_MW, $ts );
2949 break;
2950 case 'localtimestamp':
2951 $value = MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' );
2952 break;
2953 case 'currentversion':
2954 $value = SpecialVersion::getVersion();
2955 break;
2956 case 'articlepath':
2957 return $this->svcOptions->get( 'ArticlePath' );
2958 case 'sitename':
2959 return $this->svcOptions->get( 'Sitename' );
2960 case 'server':
2961 return $this->svcOptions->get( 'Server' );
2962 case 'servername':
2963 return $this->svcOptions->get( 'ServerName' );
2964 case 'scriptpath':
2965 return $this->svcOptions->get( 'ScriptPath' );
2966 case 'stylepath':
2967 return $this->svcOptions->get( 'StylePath' );
2968 case 'directionmark':
2969 return $pageLang->getDirMark();
2970 case 'contentlanguage':
2971 return $this->svcOptions->get( 'LanguageCode' );
2972 case 'pagelanguage':
2973 $value = $pageLang->getCode();
2974 break;
2975 case 'cascadingsources':
2976 $value = CoreParserFunctions::cascadingsources( $this );
2977 break;
2978 default:
2979 $ret = null;
2980 Hooks::run(
2981 'ParserGetVariableValueSwitch',
2982 [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
2983 );
2984
2985 return $ret;
2986 }
2987
2988 if ( $index ) {
2989 $this->mVarCache[$index] = $value;
2990 }
2991
2992 return $value;
2993 }
2994
2995 /**
2996 * @param int $start
2997 * @param int $len
2998 * @param int $mtts Max time-till-save; sets vary-revision-timestamp if result changes by then
2999 * @param string $variable Parser variable name
3000 * @return string
3001 */
3002 private function getRevisionTimestampSubstring( $start, $len, $mtts, $variable ) {
3003 # Get the timezone-adjusted timestamp to be used for this revision
3004 $resNow = substr( $this->getRevisionTimestamp(), $start, $len );
3005 # Possibly set vary-revision if there is not yet an associated revision
3006 if ( !$this->getRevisionObject() ) {
3007 # Get the timezone-adjusted timestamp $mtts seconds in the future.
3008 # This future is relative to the current time and not that of the
3009 # parser options. The rendered timestamp can be compared to that
3010 # of the timestamp specified by the parser options.
3011 $resThen = substr(
3012 $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ),
3013 $start,
3014 $len
3015 );
3016
3017 if ( $resNow !== $resThen ) {
3018 # Inform the edit saving system that getting the canonical output after
3019 # revision insertion requires a parse that used an actual revision timestamp
3020 $this->setOutputFlag( 'vary-revision-timestamp', "$variable used" );
3021 }
3022 }
3023
3024 return $resNow;
3025 }
3026
3027 /**
3028 * initialise the magic variables (like CURRENTMONTHNAME) and substitution modifiers
3029 *
3030 * @private
3031 */
3032 public function initialiseVariables() {
3033 $variableIDs = $this->magicWordFactory->getVariableIDs();
3034 $substIDs = $this->magicWordFactory->getSubstIDs();
3035
3036 $this->mVariables = $this->magicWordFactory->newArray( $variableIDs );
3037 $this->mSubstWords = $this->magicWordFactory->newArray( $substIDs );
3038 }
3039
3040 /**
3041 * Preprocess some wikitext and return the document tree.
3042 * This is the ghost of replace_variables().
3043 *
3044 * @param string $text The text to parse
3045 * @param int $flags Bitwise combination of:
3046 * - self::PTD_FOR_INCLUSION: Handle "<noinclude>" and "<includeonly>" as if the text is being
3047 * included. Default is to assume a direct page view.
3048 *
3049 * The generated DOM tree must depend only on the input text and the flags.
3050 * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
3051 *
3052 * Any flag added to the $flags parameter here, or any other parameter liable to cause a
3053 * change in the DOM tree for a given text, must be passed through the section identifier
3054 * in the section edit link and thus back to extractSections().
3055 *
3056 * The output of this function is currently only cached in process memory, but a persistent
3057 * cache may be implemented at a later date which takes further advantage of these strict
3058 * dependency requirements.
3059 *
3060 * @return PPNode
3061 */
3062 public function preprocessToDom( $text, $flags = 0 ) {
3063 $dom = $this->getPreprocessor()->preprocessToObj( $text, $flags );
3064 return $dom;
3065 }
3066
3067 /**
3068 * Return a three-element array: leading whitespace, string contents, trailing whitespace
3069 *
3070 * @param string $s
3071 *
3072 * @return array
3073 */
3074 public static function splitWhitespace( $s ) {
3075 $ltrimmed = ltrim( $s );
3076 $w1 = substr( $s, 0, strlen( $s ) - strlen( $ltrimmed ) );
3077 $trimmed = rtrim( $ltrimmed );
3078 $diff = strlen( $ltrimmed ) - strlen( $trimmed );
3079 if ( $diff > 0 ) {
3080 $w2 = substr( $ltrimmed, -$diff );
3081 } else {
3082 $w2 = '';
3083 }
3084 return [ $w1, $trimmed, $w2 ];
3085 }
3086
3087 /**
3088 * Replace magic variables, templates, and template arguments
3089 * with the appropriate text. Templates are substituted recursively,
3090 * taking care to avoid infinite loops.
3091 *
3092 * Note that the substitution depends on value of $mOutputType:
3093 * self::OT_WIKI: only {{subst:}} templates
3094 * self::OT_PREPROCESS: templates but not extension tags
3095 * self::OT_HTML: all templates and extension tags
3096 *
3097 * @param string $text The text to transform
3098 * @param false|PPFrame|array $frame Object describing the arguments passed to the
3099 * template. Arguments may also be provided as an associative array, as
3100 * was the usual case before MW1.12. Providing arguments this way may be
3101 * useful for extensions wishing to perform variable replacement
3102 * explicitly.
3103 * @param bool $argsOnly Only do argument (triple-brace) expansion, not
3104 * double-brace expansion.
3105 * @return string
3106 */
3107 public function replaceVariables( $text, $frame = false, $argsOnly = false ) {
3108 # Is there any text? Also, Prevent too big inclusions!
3109 $textSize = strlen( $text );
3110 if ( $textSize < 1 || $textSize > $this->mOptions->getMaxIncludeSize() ) {
3111 return $text;
3112 }
3113
3114 if ( $frame === false ) {
3115 $frame = $this->getPreprocessor()->newFrame();
3116 } elseif ( !( $frame instanceof PPFrame ) ) {
3117 $this->logger->debug(
3118 __METHOD__ . " called using plain parameters instead of " .
3119 "a PPFrame instance. Creating custom frame."
3120 );
3121 $frame = $this->getPreprocessor()->newCustomFrame( $frame );
3122 }
3123
3124 $dom = $this->preprocessToDom( $text );
3125 $flags = $argsOnly ? PPFrame::NO_TEMPLATES : 0;
3126 $text = $frame->expand( $dom, $flags );
3127
3128 return $text;
3129 }
3130
3131 /**
3132 * Clean up argument array - refactored in 1.9 so parserfunctions can use it, too.
3133 *
3134 * @param array $args
3135 *
3136 * @return array
3137 */
3138 public static function createAssocArgs( $args ) {
3139 $assocArgs = [];
3140 $index = 1;
3141 foreach ( $args as $arg ) {
3142 $eqpos = strpos( $arg, '=' );
3143 if ( $eqpos === false ) {
3144 $assocArgs[$index++] = $arg;
3145 } else {
3146 $name = trim( substr( $arg, 0, $eqpos ) );
3147 $value = trim( substr( $arg, $eqpos + 1 ) );
3148 if ( $value === false ) {
3149 $value = '';
3150 }
3151 if ( $name !== false ) {
3152 $assocArgs[$name] = $value;
3153 }
3154 }
3155 }
3156
3157 return $assocArgs;
3158 }
3159
3160 /**
3161 * Warn the user when a parser limitation is reached
3162 * Will warn at most once the user per limitation type
3163 *
3164 * The results are shown during preview and run through the Parser (See EditPage.php)
3165 *
3166 * @param string $limitationType Should be one of:
3167 * 'expensive-parserfunction' (corresponding messages:
3168 * 'expensive-parserfunction-warning',
3169 * 'expensive-parserfunction-category')
3170 * 'post-expand-template-argument' (corresponding messages:
3171 * 'post-expand-template-argument-warning',
3172 * 'post-expand-template-argument-category')
3173 * 'post-expand-template-inclusion' (corresponding messages:
3174 * 'post-expand-template-inclusion-warning',
3175 * 'post-expand-template-inclusion-category')
3176 * 'node-count-exceeded' (corresponding messages:
3177 * 'node-count-exceeded-warning',
3178 * 'node-count-exceeded-category')
3179 * 'expansion-depth-exceeded' (corresponding messages:
3180 * 'expansion-depth-exceeded-warning',
3181 * 'expansion-depth-exceeded-category')
3182 * @param string|int|null $current Current value
3183 * @param string|int|null $max Maximum allowed, when an explicit limit has been
3184 * exceeded, provide the values (optional)
3185 */
3186 public function limitationWarn( $limitationType, $current = '', $max = '' ) {
3187 # does no harm if $current and $max are present but are unnecessary for the message
3188 # Not doing ->inLanguage( $this->mOptions->getUserLangObj() ), since this is shown
3189 # only during preview, and that would split the parser cache unnecessarily.
3190 $warning = wfMessage( "$limitationType-warning" )->numParams( $current, $max )
3191 ->text();
3192 $this->mOutput->addWarning( $warning );
3193 $this->addTrackingCategory( "$limitationType-category" );
3194 }
3195
3196 /**
3197 * Return the text of a template, after recursively
3198 * replacing any variables or templates within the template.
3199 *
3200 * @param array $piece The parts of the template
3201 * $piece['title']: the title, i.e. the part before the |
3202 * $piece['parts']: the parameter array
3203 * $piece['lineStart']: whether the brace was at the start of a line
3204 * @param PPFrame $frame The current frame, contains template arguments
3205 * @throws Exception
3206 * @return string|array The text of the template
3207 */
3208 public function braceSubstitution( $piece, $frame ) {
3209 // Flags
3210
3211 // $text has been filled
3212 $found = false;
3213 // wiki markup in $text should be escaped
3214 $nowiki = false;
3215 // $text is HTML, armour it against wikitext transformation
3216 $isHTML = false;
3217 // Force interwiki transclusion to be done in raw mode not rendered
3218 $forceRawInterwiki = false;
3219 // $text is a DOM node needing expansion in a child frame
3220 $isChildObj = false;
3221 // $text is a DOM node needing expansion in the current frame
3222 $isLocalObj = false;
3223
3224 # Title object, where $text came from
3225 $title = false;
3226
3227 # $part1 is the bit before the first |, and must contain only title characters.
3228 # Various prefixes will be stripped from it later.
3229 $titleWithSpaces = $frame->expand( $piece['title'] );
3230 $part1 = trim( $titleWithSpaces );
3231 $titleText = false;
3232
3233 # Original title text preserved for various purposes
3234 $originalTitle = $part1;
3235
3236 # $args is a list of argument nodes, starting from index 0, not including $part1
3237 # @todo FIXME: If piece['parts'] is null then the call to getLength()
3238 # below won't work b/c this $args isn't an object
3239 $args = ( $piece['parts'] == null ) ? [] : $piece['parts'];
3240
3241 $profileSection = null; // profile templates
3242
3243 # SUBST
3244 if ( !$found ) {
3245 $substMatch = $this->mSubstWords->matchStartAndRemove( $part1 );
3246
3247 # Possibilities for substMatch: "subst", "safesubst" or FALSE
3248 # Decide whether to expand template or keep wikitext as-is.
3249 if ( $this->ot['wiki'] ) {
3250 if ( $substMatch === false ) {
3251 $literal = true; # literal when in PST with no prefix
3252 } else {
3253 $literal = false; # expand when in PST with subst: or safesubst:
3254 }
3255 } else {
3256 if ( $substMatch == 'subst' ) {
3257 $literal = true; # literal when not in PST with plain subst:
3258 } else {
3259 $literal = false; # expand when not in PST with safesubst: or no prefix
3260 }
3261 }
3262 if ( $literal ) {
3263 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3264 $isLocalObj = true;
3265 $found = true;
3266 }
3267 }
3268
3269 # Variables
3270 if ( !$found && $args->getLength() == 0 ) {
3271 $id = $this->mVariables->matchStartToEnd( $part1 );
3272 if ( $id !== false ) {
3273 $text = $this->getVariableValue( $id, $frame );
3274 if ( $this->magicWordFactory->getCacheTTL( $id ) > -1 ) {
3275 $this->mOutput->updateCacheExpiry(
3276 $this->magicWordFactory->getCacheTTL( $id ) );
3277 }
3278 $found = true;
3279 }
3280 }
3281
3282 # MSG, MSGNW and RAW
3283 if ( !$found ) {
3284 # Check for MSGNW:
3285 $mwMsgnw = $this->magicWordFactory->get( 'msgnw' );
3286 if ( $mwMsgnw->matchStartAndRemove( $part1 ) ) {
3287 $nowiki = true;
3288 } else {
3289 # Remove obsolete MSG:
3290 $mwMsg = $this->magicWordFactory->get( 'msg' );
3291 $mwMsg->matchStartAndRemove( $part1 );
3292 }
3293
3294 # Check for RAW:
3295 $mwRaw = $this->magicWordFactory->get( 'raw' );
3296 if ( $mwRaw->matchStartAndRemove( $part1 ) ) {
3297 $forceRawInterwiki = true;
3298 }
3299 }
3300
3301 # Parser functions
3302 if ( !$found ) {
3303 $colonPos = strpos( $part1, ':' );
3304 if ( $colonPos !== false ) {
3305 $func = substr( $part1, 0, $colonPos );
3306 $funcArgs = [ trim( substr( $part1, $colonPos + 1 ) ) ];
3307 $argsLength = $args->getLength();
3308 for ( $i = 0; $i < $argsLength; $i++ ) {
3309 $funcArgs[] = $args->item( $i );
3310 }
3311
3312 $result = $this->callParserFunction( $frame, $func, $funcArgs );
3313
3314 // Extract any forwarded flags
3315 if ( isset( $result['title'] ) ) {
3316 $title = $result['title'];
3317 }
3318 if ( isset( $result['found'] ) ) {
3319 $found = $result['found'];
3320 }
3321 if ( array_key_exists( 'text', $result ) ) {
3322 // a string or null
3323 $text = $result['text'];
3324 }
3325 if ( isset( $result['nowiki'] ) ) {
3326 $nowiki = $result['nowiki'];
3327 }
3328 if ( isset( $result['isHTML'] ) ) {
3329 $isHTML = $result['isHTML'];
3330 }
3331 if ( isset( $result['forceRawInterwiki'] ) ) {
3332 $forceRawInterwiki = $result['forceRawInterwiki'];
3333 }
3334 if ( isset( $result['isChildObj'] ) ) {
3335 $isChildObj = $result['isChildObj'];
3336 }
3337 if ( isset( $result['isLocalObj'] ) ) {
3338 $isLocalObj = $result['isLocalObj'];
3339 }
3340 }
3341 }
3342
3343 # Finish mangling title and then check for loops.
3344 # Set $title to a Title object and $titleText to the PDBK
3345 if ( !$found ) {
3346 $ns = NS_TEMPLATE;
3347 # Split the title into page and subpage
3348 $subpage = '';
3349 $relative = $this->maybeDoSubpageLink( $part1, $subpage );
3350 if ( $part1 !== $relative ) {
3351 $part1 = $relative;
3352 $ns = $this->mTitle->getNamespace();
3353 }
3354 $title = Title::newFromText( $part1, $ns );
3355 if ( $title ) {
3356 $titleText = $title->getPrefixedText();
3357 # Check for language variants if the template is not found
3358 if ( $this->getTargetLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
3359 $this->getTargetLanguage()->findVariantLink( $part1, $title, true );
3360 }
3361 # Do recursion depth check
3362 $limit = $this->mOptions->getMaxTemplateDepth();
3363 if ( $frame->depth >= $limit ) {
3364 $found = true;
3365 $text = '<span class="error">'
3366 . wfMessage( 'parser-template-recursion-depth-warning' )
3367 ->numParams( $limit )->inContentLanguage()->text()
3368 . '</span>';
3369 }
3370 }
3371 }
3372
3373 # Load from database
3374 if ( !$found && $title ) {
3375 $profileSection = $this->mProfiler->scopedProfileIn( $title->getPrefixedDBkey() );
3376 if ( !$title->isExternal() ) {
3377 if ( $title->isSpecialPage()
3378 && $this->mOptions->getAllowSpecialInclusion()
3379 && $this->ot['html']
3380 ) {
3381 $specialPage = $this->specialPageFactory->getPage( $title->getDBkey() );
3382 // Pass the template arguments as URL parameters.
3383 // "uselang" will have no effect since the Language object
3384 // is forced to the one defined in ParserOptions.
3385 $pageArgs = [];
3386 $argsLength = $args->getLength();
3387 for ( $i = 0; $i < $argsLength; $i++ ) {
3388 $bits = $args->item( $i )->splitArg();
3389 if ( strval( $bits['index'] ) === '' ) {
3390 $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
3391 $value = trim( $frame->expand( $bits['value'] ) );
3392 $pageArgs[$name] = $value;
3393 }
3394 }
3395
3396 // Create a new context to execute the special page
3397 $context = new RequestContext;
3398 $context->setTitle( $title );
3399 $context->setRequest( new FauxRequest( $pageArgs ) );
3400 if ( $specialPage && $specialPage->maxIncludeCacheTime() === 0 ) {
3401 $context->setUser( $this->getUser() );
3402 } else {
3403 // If this page is cached, then we better not be per user.
3404 $context->setUser( User::newFromName( '127.0.0.1', false ) );
3405 }
3406 $context->setLanguage( $this->mOptions->getUserLangObj() );
3407 $ret = $this->specialPageFactory->capturePath( $title, $context, $this->getLinkRenderer() );
3408 if ( $ret ) {
3409 $text = $context->getOutput()->getHTML();
3410 $this->mOutput->addOutputPageMetadata( $context->getOutput() );
3411 $found = true;
3412 $isHTML = true;
3413 if ( $specialPage && $specialPage->maxIncludeCacheTime() !== false ) {
3414 $this->mOutput->updateRuntimeAdaptiveExpiry(
3415 $specialPage->maxIncludeCacheTime()
3416 );
3417 }
3418 }
3419 } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) {
3420 $found = false; # access denied
3421 $this->logger->debug(
3422 __METHOD__ .
3423 ": template inclusion denied for " . $title->getPrefixedDBkey()
3424 );
3425 } else {
3426 list( $text, $title ) = $this->getTemplateDom( $title );
3427 if ( $text !== false ) {
3428 $found = true;
3429 $isChildObj = true;
3430 }
3431 }
3432
3433 # If the title is valid but undisplayable, make a link to it
3434 if ( !$found && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3435 $text = "[[:$titleText]]";
3436 $found = true;
3437 }
3438 } elseif ( $title->isTrans() ) {
3439 # Interwiki transclusion
3440 if ( $this->ot['html'] && !$forceRawInterwiki ) {
3441 $text = $this->interwikiTransclude( $title, 'render' );
3442 $isHTML = true;
3443 } else {
3444 $text = $this->interwikiTransclude( $title, 'raw' );
3445 # Preprocess it like a template
3446 $text = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3447 $isChildObj = true;
3448 }
3449 $found = true;
3450 }
3451
3452 # Do infinite loop check
3453 # This has to be done after redirect resolution to avoid infinite loops via redirects
3454 if ( !$frame->loopCheck( $title ) ) {
3455 $found = true;
3456 $text = '<span class="error">'
3457 . wfMessage( 'parser-template-loop-warning', $titleText )->inContentLanguage()->text()
3458 . '</span>';
3459 $this->addTrackingCategory( 'template-loop-category' );
3460 $this->mOutput->addWarning( wfMessage( 'template-loop-warning',
3461 wfEscapeWikiText( $titleText ) )->text() );
3462 $this->logger->debug( __METHOD__ . ": template loop broken at '$titleText'" );
3463 }
3464 }
3465
3466 # If we haven't found text to substitute by now, we're done
3467 # Recover the source wikitext and return it
3468 if ( !$found ) {
3469 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3470 if ( $profileSection ) {
3471 $this->mProfiler->scopedProfileOut( $profileSection );
3472 }
3473 return [ 'object' => $text ];
3474 }
3475
3476 # Expand DOM-style return values in a child frame
3477 if ( $isChildObj ) {
3478 # Clean up argument array
3479 $newFrame = $frame->newChild( $args, $title );
3480
3481 if ( $nowiki ) {
3482 $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
3483 } elseif ( $titleText !== false && $newFrame->isEmpty() ) {
3484 # Expansion is eligible for the empty-frame cache
3485 $text = $newFrame->cachedExpand( $titleText, $text );
3486 } else {
3487 # Uncached expansion
3488 $text = $newFrame->expand( $text );
3489 }
3490 }
3491 if ( $isLocalObj && $nowiki ) {
3492 $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
3493 $isLocalObj = false;
3494 }
3495
3496 if ( $profileSection ) {
3497 $this->mProfiler->scopedProfileOut( $profileSection );
3498 }
3499
3500 # Replace raw HTML by a placeholder
3501 if ( $isHTML ) {
3502 $text = $this->insertStripItem( $text );
3503 } elseif ( $nowiki && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3504 # Escape nowiki-style return values
3505 $text = wfEscapeWikiText( $text );
3506 } elseif ( is_string( $text )
3507 && !$piece['lineStart']
3508 && preg_match( '/^(?:{\\||:|;|#|\*)/', $text )
3509 ) {
3510 # T2529: if the template begins with a table or block-level
3511 # element, it should be treated as beginning a new line.
3512 # This behavior is somewhat controversial.
3513 $text = "\n" . $text;
3514 }
3515
3516 if ( is_string( $text ) && !$this->incrementIncludeSize( 'post-expand', strlen( $text ) ) ) {
3517 # Error, oversize inclusion
3518 if ( $titleText !== false ) {
3519 # Make a working, properly escaped link if possible (T25588)
3520 $text = "[[:$titleText]]";
3521 } else {
3522 # This will probably not be a working link, but at least it may
3523 # provide some hint of where the problem is
3524 preg_replace( '/^:/', '', $originalTitle );
3525 $text = "[[:$originalTitle]]";
3526 }
3527 $text .= $this->insertStripItem( '<!-- WARNING: template omitted, '
3528 . 'post-expand include size too large -->' );
3529 $this->limitationWarn( 'post-expand-template-inclusion' );
3530 }
3531
3532 if ( $isLocalObj ) {
3533 $ret = [ 'object' => $text ];
3534 } else {
3535 $ret = [ 'text' => $text ];
3536 }
3537
3538 return $ret;
3539 }
3540
3541 /**
3542 * Call a parser function and return an array with text and flags.
3543 *
3544 * The returned array will always contain a boolean 'found', indicating
3545 * whether the parser function was found or not. It may also contain the
3546 * following:
3547 * text: string|object, resulting wikitext or PP DOM object
3548 * isHTML: bool, $text is HTML, armour it against wikitext transformation
3549 * isChildObj: bool, $text is a DOM node needing expansion in a child frame
3550 * isLocalObj: bool, $text is a DOM node needing expansion in the current frame
3551 * nowiki: bool, wiki markup in $text should be escaped
3552 *
3553 * @since 1.21
3554 * @param PPFrame $frame The current frame, contains template arguments
3555 * @param string $function Function name
3556 * @param array $args Arguments to the function
3557 * @throws MWException
3558 * @return array
3559 */
3560 public function callParserFunction( $frame, $function, array $args = [] ) {
3561 # Case sensitive functions
3562 if ( isset( $this->mFunctionSynonyms[1][$function] ) ) {
3563 $function = $this->mFunctionSynonyms[1][$function];
3564 } else {
3565 # Case insensitive functions
3566 $function = $this->contLang->lc( $function );
3567 if ( isset( $this->mFunctionSynonyms[0][$function] ) ) {
3568 $function = $this->mFunctionSynonyms[0][$function];
3569 } else {
3570 return [ 'found' => false ];
3571 }
3572 }
3573
3574 list( $callback, $flags ) = $this->mFunctionHooks[$function];
3575
3576 // Avoid PHP 7.1 warning from passing $this by reference
3577 $parser = $this;
3578
3579 $allArgs = [ &$parser ];
3580 if ( $flags & self::SFH_OBJECT_ARGS ) {
3581 # Convert arguments to PPNodes and collect for appending to $allArgs
3582 $funcArgs = [];
3583 foreach ( $args as $k => $v ) {
3584 if ( $v instanceof PPNode || $k === 0 ) {
3585 $funcArgs[] = $v;
3586 } else {
3587 $funcArgs[] = $this->mPreprocessor->newPartNodeArray( [ $k => $v ] )->item( 0 );
3588 }
3589 }
3590
3591 # Add a frame parameter, and pass the arguments as an array
3592 $allArgs[] = $frame;
3593 $allArgs[] = $funcArgs;
3594 } else {
3595 # Convert arguments to plain text and append to $allArgs
3596 foreach ( $args as $k => $v ) {
3597 if ( $v instanceof PPNode ) {
3598 $allArgs[] = trim( $frame->expand( $v ) );
3599 } elseif ( is_int( $k ) && $k >= 0 ) {
3600 $allArgs[] = trim( $v );
3601 } else {
3602 $allArgs[] = trim( "$k=$v" );
3603 }
3604 }
3605 }
3606
3607 $result = $callback( ...$allArgs );
3608
3609 # The interface for function hooks allows them to return a wikitext
3610 # string or an array containing the string and any flags. This mungs
3611 # things around to match what this method should return.
3612 if ( !is_array( $result ) ) {
3613 $result = [
3614 'found' => true,
3615 'text' => $result,
3616 ];
3617 } else {
3618 if ( isset( $result[0] ) && !isset( $result['text'] ) ) {
3619 $result['text'] = $result[0];
3620 }
3621 unset( $result[0] );
3622 $result += [
3623 'found' => true,
3624 ];
3625 }
3626
3627 $noparse = true;
3628 $preprocessFlags = 0;
3629 if ( isset( $result['noparse'] ) ) {
3630 $noparse = $result['noparse'];
3631 }
3632 if ( isset( $result['preprocessFlags'] ) ) {
3633 $preprocessFlags = $result['preprocessFlags'];
3634 }
3635
3636 if ( !$noparse ) {
3637 $result['text'] = $this->preprocessToDom( $result['text'], $preprocessFlags );
3638 $result['isChildObj'] = true;
3639 }
3640
3641 return $result;
3642 }
3643
3644 /**
3645 * Get the semi-parsed DOM representation of a template with a given title,
3646 * and its redirect destination title. Cached.
3647 *
3648 * @param Title $title
3649 *
3650 * @return array
3651 */
3652 public function getTemplateDom( $title ) {
3653 $cacheTitle = $title;
3654 $titleText = $title->getPrefixedDBkey();
3655
3656 if ( isset( $this->mTplRedirCache[$titleText] ) ) {
3657 list( $ns, $dbk ) = $this->mTplRedirCache[$titleText];
3658 $title = Title::makeTitle( $ns, $dbk );
3659 $titleText = $title->getPrefixedDBkey();
3660 }
3661 if ( isset( $this->mTplDomCache[$titleText] ) ) {
3662 return [ $this->mTplDomCache[$titleText], $title ];
3663 }
3664
3665 # Cache miss, go to the database
3666 list( $text, $title ) = $this->fetchTemplateAndTitle( $title );
3667
3668 if ( $text === false ) {
3669 $this->mTplDomCache[$titleText] = false;
3670 return [ false, $title ];
3671 }
3672
3673 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3674 $this->mTplDomCache[$titleText] = $dom;
3675
3676 if ( !$title->equals( $cacheTitle ) ) {
3677 $this->mTplRedirCache[$cacheTitle->getPrefixedDBkey()] =
3678 [ $title->getNamespace(), $title->getDBkey() ];
3679 }
3680
3681 return [ $dom, $title ];
3682 }
3683
3684 /**
3685 * Fetch the current revision of a given title. Note that the revision
3686 * (and even the title) may not exist in the database, so everything
3687 * contributing to the output of the parser should use this method
3688 * where possible, rather than getting the revisions themselves. This
3689 * method also caches its results, so using it benefits performance.
3690 *
3691 * @since 1.24
3692 * @param Title $title
3693 * @return Revision
3694 */
3695 public function fetchCurrentRevisionOfTitle( $title ) {
3696 $cacheKey = $title->getPrefixedDBkey();
3697 if ( !$this->currentRevisionCache ) {
3698 $this->currentRevisionCache = new MapCacheLRU( 100 );
3699 }
3700 if ( !$this->currentRevisionCache->has( $cacheKey ) ) {
3701 $this->currentRevisionCache->set( $cacheKey,
3702 // Defaults to Parser::statelessFetchRevision()
3703 call_user_func( $this->mOptions->getCurrentRevisionCallback(), $title, $this )
3704 );
3705 }
3706 return $this->currentRevisionCache->get( $cacheKey );
3707 }
3708
3709 /**
3710 * @param Title $title
3711 * @return bool
3712 * @since 1.34
3713 */
3714 public function isCurrentRevisionOfTitleCached( $title ) {
3715 return (
3716 $this->currentRevisionCache &&
3717 $this->currentRevisionCache->has( $title->getPrefixedText() )
3718 );
3719 }
3720
3721 /**
3722 * Wrapper around Revision::newFromTitle to allow passing additional parameters
3723 * without passing them on to it.
3724 *
3725 * @since 1.24
3726 * @param Title $title
3727 * @param Parser|bool $parser
3728 * @return Revision|bool False if missing
3729 */
3730 public static function statelessFetchRevision( Title $title, $parser = false ) {
3731 $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
3732
3733 return $rev;
3734 }
3735
3736 /**
3737 * Fetch the unparsed text of a template and register a reference to it.
3738 * @param Title $title
3739 * @return array ( string or false, Title )
3740 */
3741 public function fetchTemplateAndTitle( $title ) {
3742 // Defaults to Parser::statelessFetchTemplate()
3743 $templateCb = $this->mOptions->getTemplateCallback();
3744 $stuff = call_user_func( $templateCb, $title, $this );
3745 $rev = $stuff['revision'] ?? null;
3746 $text = $stuff['text'];
3747 if ( is_string( $stuff['text'] ) ) {
3748 // We use U+007F DELETE to distinguish strip markers from regular text
3749 $text = strtr( $text, "\x7f", "?" );
3750 }
3751 $finalTitle = $stuff['finalTitle'] ?? $title;
3752 foreach ( ( $stuff['deps'] ?? [] ) as $dep ) {
3753 $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
3754 if ( $dep['title']->equals( $this->getTitle() ) && $rev instanceof Revision ) {
3755 // Self-transclusion; final result may change based on the new page version
3756 $this->setOutputFlag( 'vary-revision-sha1', 'Self transclusion' );
3757 $this->getOutput()->setRevisionUsedSha1Base36( $rev->getSha1() );
3758 }
3759 }
3760
3761 return [ $text, $finalTitle ];
3762 }
3763
3764 /**
3765 * Fetch the unparsed text of a template and register a reference to it.
3766 * @param Title $title
3767 * @return string|bool
3768 */
3769 public function fetchTemplate( $title ) {
3770 return $this->fetchTemplateAndTitle( $title )[0];
3771 }
3772
3773 /**
3774 * Static function to get a template
3775 * Can be overridden via ParserOptions::setTemplateCallback().
3776 *
3777 * @param Title $title
3778 * @param bool|Parser $parser
3779 *
3780 * @return array
3781 */
3782 public static function statelessFetchTemplate( $title, $parser = false ) {
3783 $text = $skip = false;
3784 $finalTitle = $title;
3785 $deps = [];
3786 $rev = null;
3787
3788 # Loop to fetch the article, with up to 1 redirect
3789 for ( $i = 0; $i < 2 && is_object( $title ); $i++ ) {
3790 # Give extensions a chance to select the revision instead
3791 $id = false; # Assume current
3792 Hooks::run( 'BeforeParserFetchTemplateAndtitle',
3793 [ $parser, $title, &$skip, &$id ] );
3794
3795 if ( $skip ) {
3796 $text = false;
3797 $deps[] = [
3798 'title' => $title,
3799 'page_id' => $title->getArticleID(),
3800 'rev_id' => null
3801 ];
3802 break;
3803 }
3804 # Get the revision
3805 if ( $id ) {
3806 $rev = Revision::newFromId( $id );
3807 } elseif ( $parser ) {
3808 $rev = $parser->fetchCurrentRevisionOfTitle( $title );
3809 } else {
3810 $rev = Revision::newFromTitle( $title );
3811 }
3812 $rev_id = $rev ? $rev->getId() : 0;
3813 # If there is no current revision, there is no page
3814 if ( $id === false && !$rev ) {
3815 $linkCache = MediaWikiServices::getInstance()->getLinkCache();
3816 $linkCache->addBadLinkObj( $title );
3817 }
3818
3819 $deps[] = [
3820 'title' => $title,
3821 'page_id' => $title->getArticleID(),
3822 'rev_id' => $rev_id
3823 ];
3824 if ( $rev && !$title->equals( $rev->getTitle() ) ) {
3825 # We fetched a rev from a different title; register it too...
3826 $deps[] = [
3827 'title' => $rev->getTitle(),
3828 'page_id' => $rev->getPage(),
3829 'rev_id' => $rev_id
3830 ];
3831 }
3832
3833 if ( $rev ) {
3834 $content = $rev->getContent();
3835 $text = $content ? $content->getWikitextForTransclusion() : null;
3836
3837 Hooks::run( 'ParserFetchTemplate',
3838 [ $parser, $title, $rev, &$text, &$deps ] );
3839
3840 if ( $text === false || $text === null ) {
3841 $text = false;
3842 break;
3843 }
3844 } elseif ( $title->getNamespace() == NS_MEDIAWIKI ) {
3845 $message = wfMessage( MediaWikiServices::getInstance()->getContentLanguage()->
3846 lcfirst( $title->getText() ) )->inContentLanguage();
3847 if ( !$message->exists() ) {
3848 $text = false;
3849 break;
3850 }
3851 $content = $message->content();
3852 $text = $message->plain();
3853 } else {
3854 break;
3855 }
3856 if ( !$content ) {
3857 break;
3858 }
3859 # Redirect?
3860 $finalTitle = $title;
3861 $title = $content->getRedirectTarget();
3862 }
3863 return [
3864 'revision' => $rev,
3865 'text' => $text,
3866 'finalTitle' => $finalTitle,
3867 'deps' => $deps
3868 ];
3869 }
3870
3871 /**
3872 * Fetch a file and its title and register a reference to it.
3873 * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
3874 * @param Title $title
3875 * @param array $options Array of options to RepoGroup::findFile
3876 * @return array ( File or false, Title of file )
3877 */
3878 public function fetchFileAndTitle( $title, $options = [] ) {
3879 $file = $this->fetchFileNoRegister( $title, $options );
3880
3881 $time = $file ? $file->getTimestamp() : false;
3882 $sha1 = $file ? $file->getSha1() : false;
3883 # Register the file as a dependency...
3884 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3885 if ( $file && !$title->equals( $file->getTitle() ) ) {
3886 # Update fetched file title
3887 $title = $file->getTitle();
3888 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3889 }
3890 return [ $file, $title ];
3891 }
3892
3893 /**
3894 * Helper function for fetchFileAndTitle.
3895 *
3896 * Also useful if you need to fetch a file but not use it yet,
3897 * for example to get the file's handler.
3898 *
3899 * @param Title $title
3900 * @param array $options Array of options to RepoGroup::findFile
3901 * @return File|bool
3902 */
3903 protected function fetchFileNoRegister( $title, $options = [] ) {
3904 if ( isset( $options['broken'] ) ) {
3905 $file = false; // broken thumbnail forced by hook
3906 } elseif ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp)
3907 $file = RepoGroup::singleton()->findFileFromKey( $options['sha1'], $options );
3908 } else { // get by (name,timestamp)
3909 $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title, $options );
3910 }
3911 return $file;
3912 }
3913
3914 /**
3915 * Transclude an interwiki link.
3916 *
3917 * @param Title $title
3918 * @param string $action Usually one of (raw, render)
3919 *
3920 * @return string
3921 */
3922 public function interwikiTransclude( $title, $action ) {
3923 if ( !$this->svcOptions->get( 'EnableScaryTranscluding' ) ) {
3924 return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text();
3925 }
3926
3927 $url = $title->getFullURL( [ 'action' => $action ] );
3928 if ( strlen( $url ) > 1024 ) {
3929 return wfMessage( 'scarytranscludetoolong' )->inContentLanguage()->text();
3930 }
3931
3932 $wikiId = $title->getTransWikiID(); // remote wiki ID or false
3933
3934 $fname = __METHOD__;
3935 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
3936
3937 $data = $cache->getWithSetCallback(
3938 $cache->makeGlobalKey(
3939 'interwiki-transclude',
3940 ( $wikiId !== false ) ? $wikiId : 'external',
3941 sha1( $url )
3942 ),
3943 $this->svcOptions->get( 'TranscludeCacheExpiry' ),
3944 function ( $oldValue, &$ttl ) use ( $url, $fname, $cache ) {
3945 $req = MWHttpRequest::factory( $url, [], $fname );
3946
3947 $status = $req->execute(); // Status object
3948 if ( !$status->isOK() ) {
3949 $ttl = $cache::TTL_UNCACHEABLE;
3950 } elseif ( $req->getResponseHeader( 'X-Database-Lagged' ) !== null ) {
3951 $ttl = min( $cache::TTL_LAGGED, $ttl );
3952 }
3953
3954 return [
3955 'text' => $status->isOK() ? $req->getContent() : null,
3956 'code' => $req->getStatus()
3957 ];
3958 },
3959 [
3960 'checkKeys' => ( $wikiId !== false )
3961 ? [ $cache->makeGlobalKey( 'interwiki-page', $wikiId, $title->getDBkey() ) ]
3962 : [],
3963 'pcGroup' => 'interwiki-transclude:5',
3964 'pcTTL' => $cache::TTL_PROC_LONG
3965 ]
3966 );
3967
3968 if ( is_string( $data['text'] ) ) {
3969 $text = $data['text'];
3970 } elseif ( $data['code'] != 200 ) {
3971 // Though we failed to fetch the content, this status is useless.
3972 $text = wfMessage( 'scarytranscludefailed-httpstatus' )
3973 ->params( $url, $data['code'] )->inContentLanguage()->text();
3974 } else {
3975 $text = wfMessage( 'scarytranscludefailed', $url )->inContentLanguage()->text();
3976 }
3977
3978 return $text;
3979 }
3980
3981 /**
3982 * Triple brace replacement -- used for template arguments
3983 * @private
3984 *
3985 * @param array $piece
3986 * @param PPFrame $frame
3987 *
3988 * @return array
3989 */
3990 public function argSubstitution( $piece, $frame ) {
3991 $error = false;
3992 $parts = $piece['parts'];
3993 $nameWithSpaces = $frame->expand( $piece['title'] );
3994 $argName = trim( $nameWithSpaces );
3995 $object = false;
3996 $text = $frame->getArgument( $argName );
3997 if ( $text === false && $parts->getLength() > 0
3998 && ( $this->ot['html']
3999 || $this->ot['pre']
4000 || ( $this->ot['wiki'] && $frame->isTemplate() )
4001 )
4002 ) {
4003 # No match in frame, use the supplied default
4004 $object = $parts->item( 0 )->getChildren();
4005 }
4006 if ( !$this->incrementIncludeSize( 'arg', strlen( $text ) ) ) {
4007 $error = '<!-- WARNING: argument omitted, expansion size too large -->';
4008 $this->limitationWarn( 'post-expand-template-argument' );
4009 }
4010
4011 if ( $text === false && $object === false ) {
4012 # No match anywhere
4013 $object = $frame->virtualBracketedImplode( '{{{', '|', '}}}', $nameWithSpaces, $parts );
4014 }
4015 if ( $error !== false ) {
4016 $text .= $error;
4017 }
4018 if ( $object !== false ) {
4019 $ret = [ 'object' => $object ];
4020 } else {
4021 $ret = [ 'text' => $text ];
4022 }
4023
4024 return $ret;
4025 }
4026
4027 /**
4028 * Return the text to be used for a given extension tag.
4029 * This is the ghost of strip().
4030 *
4031 * @param array $params Associative array of parameters:
4032 * name PPNode for the tag name
4033 * attr PPNode for unparsed text where tag attributes are thought to be
4034 * attributes Optional associative array of parsed attributes
4035 * inner Contents of extension element
4036 * noClose Original text did not have a close tag
4037 * @param PPFrame $frame
4038 *
4039 * @throws MWException
4040 * @return string
4041 */
4042 public function extensionSubstitution( $params, $frame ) {
4043 static $errorStr = '<span class="error">';
4044 static $errorLen = 20;
4045
4046 $name = $frame->expand( $params['name'] );
4047 if ( substr( $name, 0, $errorLen ) === $errorStr ) {
4048 // Probably expansion depth or node count exceeded. Just punt the
4049 // error up.
4050 return $name;
4051 }
4052
4053 $attrText = !isset( $params['attr'] ) ? null : $frame->expand( $params['attr'] );
4054 if ( substr( $attrText, 0, $errorLen ) === $errorStr ) {
4055 // See above
4056 return $attrText;
4057 }
4058
4059 // We can't safely check if the expansion for $content resulted in an
4060 // error, because the content could happen to be the error string
4061 // (T149622).
4062 $content = !isset( $params['inner'] ) ? null : $frame->expand( $params['inner'] );
4063
4064 $marker = self::MARKER_PREFIX . "-$name-"
4065 . sprintf( '%08X', $this->mMarkerIndex++ ) . self::MARKER_SUFFIX;
4066
4067 $isFunctionTag = isset( $this->mFunctionTagHooks[strtolower( $name )] ) &&
4068 ( $this->ot['html'] || $this->ot['pre'] );
4069 if ( $isFunctionTag ) {
4070 $markerType = 'none';
4071 } else {
4072 $markerType = 'general';
4073 }
4074 if ( $this->ot['html'] || $isFunctionTag ) {
4075 $name = strtolower( $name );
4076 $attributes = Sanitizer::decodeTagAttributes( $attrText );
4077 if ( isset( $params['attributes'] ) ) {
4078 $attributes += $params['attributes'];
4079 }
4080
4081 if ( isset( $this->mTagHooks[$name] ) ) {
4082 $output = call_user_func_array( $this->mTagHooks[$name],
4083 [ $content, $attributes, $this, $frame ] );
4084 } elseif ( isset( $this->mFunctionTagHooks[$name] ) ) {
4085 list( $callback, ) = $this->mFunctionTagHooks[$name];
4086
4087 // Avoid PHP 7.1 warning from passing $this by reference
4088 $parser = $this;
4089 $output = call_user_func_array( $callback, [ &$parser, $frame, $content, $attributes ] );
4090 } else {
4091 $output = '<span class="error">Invalid tag extension name: ' .
4092 htmlspecialchars( $name ) . '</span>';
4093 }
4094
4095 if ( is_array( $output ) ) {
4096 // Extract flags
4097 $flags = $output;
4098 $output = $flags[0];
4099 if ( isset( $flags['markerType'] ) ) {
4100 $markerType = $flags['markerType'];
4101 }
4102 }
4103 } else {
4104 if ( is_null( $attrText ) ) {
4105 $attrText = '';
4106 }
4107 if ( isset( $params['attributes'] ) ) {
4108 foreach ( $params['attributes'] as $attrName => $attrValue ) {
4109 $attrText .= ' ' . htmlspecialchars( $attrName ) . '="' .
4110 htmlspecialchars( $attrValue ) . '"';
4111 }
4112 }
4113 if ( $content === null ) {
4114 $output = "<$name$attrText/>";
4115 } else {
4116 $close = is_null( $params['close'] ) ? '' : $frame->expand( $params['close'] );
4117 if ( substr( $close, 0, $errorLen ) === $errorStr ) {
4118 // See above
4119 return $close;
4120 }
4121 $output = "<$name$attrText>$content$close";
4122 }
4123 }
4124
4125 if ( $markerType === 'none' ) {
4126 return $output;
4127 } elseif ( $markerType === 'nowiki' ) {
4128 $this->mStripState->addNoWiki( $marker, $output );
4129 } elseif ( $markerType === 'general' ) {
4130 $this->mStripState->addGeneral( $marker, $output );
4131 } else {
4132 throw new MWException( __METHOD__ . ': invalid marker type' );
4133 }
4134 return $marker;
4135 }
4136
4137 /**
4138 * Increment an include size counter
4139 *
4140 * @param string $type The type of expansion
4141 * @param int $size The size of the text
4142 * @return bool False if this inclusion would take it over the maximum, true otherwise
4143 */
4144 public function incrementIncludeSize( $type, $size ) {
4145 if ( $this->mIncludeSizes[$type] + $size > $this->mOptions->getMaxIncludeSize() ) {
4146 return false;
4147 } else {
4148 $this->mIncludeSizes[$type] += $size;
4149 return true;
4150 }
4151 }
4152
4153 /**
4154 * Increment the expensive function count
4155 *
4156 * @return bool False if the limit has been exceeded
4157 */
4158 public function incrementExpensiveFunctionCount() {
4159 $this->mExpensiveFunctionCount++;
4160 return $this->mExpensiveFunctionCount <= $this->mOptions->getExpensiveParserFunctionLimit();
4161 }
4162
4163 /**
4164 * Strip double-underscore items like __NOGALLERY__ and __NOTOC__
4165 * Fills $this->mDoubleUnderscores, returns the modified text
4166 *
4167 * @param string $text
4168 *
4169 * @return string
4170 */
4171 public function doDoubleUnderscore( $text ) {
4172 # The position of __TOC__ needs to be recorded
4173 $mw = $this->magicWordFactory->get( 'toc' );
4174 if ( $mw->match( $text ) ) {
4175 $this->mShowToc = true;
4176 $this->mForceTocPosition = true;
4177
4178 # Set a placeholder. At the end we'll fill it in with the TOC.
4179 $text = $mw->replace( '<!--MWTOC\'"-->', $text, 1 );
4180
4181 # Only keep the first one.
4182 $text = $mw->replace( '', $text );
4183 }
4184
4185 # Now match and remove the rest of them
4186 $mwa = $this->magicWordFactory->getDoubleUnderscoreArray();
4187 $this->mDoubleUnderscores = $mwa->matchAndRemove( $text );
4188
4189 if ( isset( $this->mDoubleUnderscores['nogallery'] ) ) {
4190 $this->mOutput->mNoGallery = true;
4191 }
4192 if ( isset( $this->mDoubleUnderscores['notoc'] ) && !$this->mForceTocPosition ) {
4193 $this->mShowToc = false;
4194 }
4195 if ( isset( $this->mDoubleUnderscores['hiddencat'] )
4196 && $this->mTitle->getNamespace() == NS_CATEGORY
4197 ) {
4198 $this->addTrackingCategory( 'hidden-category-category' );
4199 }
4200 # (T10068) Allow control over whether robots index a page.
4201 # __INDEX__ always overrides __NOINDEX__, see T16899
4202 if ( isset( $this->mDoubleUnderscores['noindex'] ) && $this->mTitle->canUseNoindex() ) {
4203 $this->mOutput->setIndexPolicy( 'noindex' );
4204 $this->addTrackingCategory( 'noindex-category' );
4205 }
4206 if ( isset( $this->mDoubleUnderscores['index'] ) && $this->mTitle->canUseNoindex() ) {
4207 $this->mOutput->setIndexPolicy( 'index' );
4208 $this->addTrackingCategory( 'index-category' );
4209 }
4210
4211 # Cache all double underscores in the database
4212 foreach ( $this->mDoubleUnderscores as $key => $val ) {
4213 $this->mOutput->setProperty( $key, '' );
4214 }
4215
4216 return $text;
4217 }
4218
4219 /**
4220 * @see ParserOutput::addTrackingCategory()
4221 * @param string $msg Message key
4222 * @return bool Whether the addition was successful
4223 */
4224 public function addTrackingCategory( $msg ) {
4225 return $this->mOutput->addTrackingCategory( $msg, $this->mTitle );
4226 }
4227
4228 /**
4229 * This function accomplishes several tasks:
4230 * 1) Auto-number headings if that option is enabled
4231 * 2) Add an [edit] link to sections for users who have enabled the option and can edit the page
4232 * 3) Add a Table of contents on the top for users who have enabled the option
4233 * 4) Auto-anchor headings
4234 *
4235 * It loops through all headlines, collects the necessary data, then splits up the
4236 * string and re-inserts the newly formatted headlines.
4237 *
4238 * @param string $text
4239 * @param string $origText Original, untouched wikitext
4240 * @param bool $isMain
4241 * @return mixed|string
4242 * @private
4243 */
4244 public function formatHeadings( $text, $origText, $isMain = true ) {
4245 # Inhibit editsection links if requested in the page
4246 if ( isset( $this->mDoubleUnderscores['noeditsection'] ) ) {
4247 $maybeShowEditLink = false;
4248 } else {
4249 $maybeShowEditLink = true; /* Actual presence will depend on post-cache transforms */
4250 }
4251
4252 # Get all headlines for numbering them and adding funky stuff like [edit]
4253 # links - this is for later, but we need the number of headlines right now
4254 # NOTE: white space in headings have been trimmed in doHeadings. They shouldn't
4255 # be trimmed here since whitespace in HTML headings is significant.
4256 $matches = [];
4257 $numMatches = preg_match_all(
4258 '/<H(?P<level>[1-6])(?P<attrib>.*?>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i',
4259 $text,
4260 $matches
4261 );
4262
4263 # if there are fewer than 4 headlines in the article, do not show TOC
4264 # unless it's been explicitly enabled.
4265 $enoughToc = $this->mShowToc &&
4266 ( ( $numMatches >= 4 ) || $this->mForceTocPosition );
4267
4268 # Allow user to stipulate that a page should have a "new section"
4269 # link added via __NEWSECTIONLINK__
4270 if ( isset( $this->mDoubleUnderscores['newsectionlink'] ) ) {
4271 $this->mOutput->setNewSection( true );
4272 }
4273
4274 # Allow user to remove the "new section"
4275 # link via __NONEWSECTIONLINK__
4276 if ( isset( $this->mDoubleUnderscores['nonewsectionlink'] ) ) {
4277 $this->mOutput->hideNewSection( true );
4278 }
4279
4280 # if the string __FORCETOC__ (not case-sensitive) occurs in the HTML,
4281 # override above conditions and always show TOC above first header
4282 if ( isset( $this->mDoubleUnderscores['forcetoc'] ) ) {
4283 $this->mShowToc = true;
4284 $enoughToc = true;
4285 }
4286
4287 # headline counter
4288 $headlineCount = 0;
4289 $numVisible = 0;
4290
4291 # Ugh .. the TOC should have neat indentation levels which can be
4292 # passed to the skin functions. These are determined here
4293 $toc = '';
4294 $full = '';
4295 $head = [];
4296 $sublevelCount = [];
4297 $levelCount = [];
4298 $level = 0;
4299 $prevlevel = 0;
4300 $toclevel = 0;
4301 $prevtoclevel = 0;
4302 $markerRegex = self::MARKER_PREFIX . "-h-(\d+)-" . self::MARKER_SUFFIX;
4303 $baseTitleText = $this->mTitle->getPrefixedDBkey();
4304 $oldType = $this->mOutputType;
4305 $this->setOutputType( self::OT_WIKI );
4306 $frame = $this->getPreprocessor()->newFrame();
4307 $root = $this->preprocessToDom( $origText );
4308 $node = $root->getFirstChild();
4309 $byteOffset = 0;
4310 $tocraw = [];
4311 $refers = [];
4312
4313 $headlines = $numMatches !== false ? $matches[3] : [];
4314
4315 $maxTocLevel = $this->svcOptions->get( 'MaxTocLevel' );
4316 foreach ( $headlines as $headline ) {
4317 $isTemplate = false;
4318 $titleText = false;
4319 $sectionIndex = false;
4320 $numbering = '';
4321 $markerMatches = [];
4322 if ( preg_match( "/^$markerRegex/", $headline, $markerMatches ) ) {
4323 $serial = $markerMatches[1];
4324 list( $titleText, $sectionIndex ) = $this->mHeadings[$serial];
4325 $isTemplate = ( $titleText != $baseTitleText );
4326 $headline = preg_replace( "/^$markerRegex\\s*/", "", $headline );
4327 }
4328
4329 if ( $toclevel ) {
4330 $prevlevel = $level;
4331 }
4332 $level = $matches[1][$headlineCount];
4333
4334 if ( $level > $prevlevel ) {
4335 # Increase TOC level
4336 $toclevel++;
4337 $sublevelCount[$toclevel] = 0;
4338 if ( $toclevel < $maxTocLevel ) {
4339 $prevtoclevel = $toclevel;
4340 $toc .= Linker::tocIndent();
4341 $numVisible++;
4342 }
4343 } elseif ( $level < $prevlevel && $toclevel > 1 ) {
4344 # Decrease TOC level, find level to jump to
4345
4346 for ( $i = $toclevel; $i > 0; $i-- ) {
4347 if ( $levelCount[$i] == $level ) {
4348 # Found last matching level
4349 $toclevel = $i;
4350 break;
4351 } elseif ( $levelCount[$i] < $level ) {
4352 # Found first matching level below current level
4353 $toclevel = $i + 1;
4354 break;
4355 }
4356 }
4357 if ( $i == 0 ) {
4358 $toclevel = 1;
4359 }
4360 if ( $toclevel < $maxTocLevel ) {
4361 if ( $prevtoclevel < $maxTocLevel ) {
4362 # Unindent only if the previous toc level was shown :p
4363 $toc .= Linker::tocUnindent( $prevtoclevel - $toclevel );
4364 $prevtoclevel = $toclevel;
4365 } else {
4366 $toc .= Linker::tocLineEnd();
4367 }
4368 }
4369 } else {
4370 # No change in level, end TOC line
4371 if ( $toclevel < $maxTocLevel ) {
4372 $toc .= Linker::tocLineEnd();
4373 }
4374 }
4375
4376 $levelCount[$toclevel] = $level;
4377
4378 # count number of headlines for each level
4379 $sublevelCount[$toclevel]++;
4380 $dot = 0;
4381 for ( $i = 1; $i <= $toclevel; $i++ ) {
4382 if ( !empty( $sublevelCount[$i] ) ) {
4383 if ( $dot ) {
4384 $numbering .= '.';
4385 }
4386 $numbering .= $this->getTargetLanguage()->formatNum( $sublevelCount[$i] );
4387 $dot = 1;
4388 }
4389 }
4390
4391 # The safe header is a version of the header text safe to use for links
4392
4393 # Remove link placeholders by the link text.
4394 # <!--LINK number-->
4395 # turns into
4396 # link text with suffix
4397 # Do this before unstrip since link text can contain strip markers
4398 $safeHeadline = $this->replaceLinkHoldersText( $headline );
4399
4400 # Avoid insertion of weird stuff like <math> by expanding the relevant sections
4401 $safeHeadline = $this->mStripState->unstripBoth( $safeHeadline );
4402
4403 # Remove any <style> or <script> tags (T198618)
4404 $safeHeadline = preg_replace(
4405 '#<(style|script)(?: [^>]*[^>/])?>.*?</\1>#is',
4406 '',
4407 $safeHeadline
4408 );
4409
4410 # Strip out HTML (first regex removes any tag not allowed)
4411 # Allowed tags are:
4412 # * <sup> and <sub> (T10393)
4413 # * <i> (T28375)
4414 # * <b> (r105284)
4415 # * <bdi> (T74884)
4416 # * <span dir="rtl"> and <span dir="ltr"> (T37167)
4417 # * <s> and <strike> (T35715)
4418 # We strip any parameter from accepted tags (second regex), except dir="rtl|ltr" from <span>,
4419 # to allow setting directionality in toc items.
4420 $tocline = preg_replace(
4421 [
4422 '#<(?!/?(span|sup|sub|bdi|i|b|s|strike)(?: [^>]*)?>).*?>#',
4423 '#<(/?(?:span(?: dir="(?:rtl|ltr)")?|sup|sub|bdi|i|b|s|strike))(?: .*?)?>#'
4424 ],
4425 [ '', '<$1>' ],
4426 $safeHeadline
4427 );
4428
4429 # Strip '<span></span>', which is the result from the above if
4430 # <span id="foo"></span> is used to produce an additional anchor
4431 # for a section.
4432 $tocline = str_replace( '<span></span>', '', $tocline );
4433
4434 $tocline = trim( $tocline );
4435
4436 # For the anchor, strip out HTML-y stuff period
4437 $safeHeadline = preg_replace( '/<.*?>/', '', $safeHeadline );
4438 $safeHeadline = Sanitizer::normalizeSectionNameWhitespace( $safeHeadline );
4439
4440 # Save headline for section edit hint before it's escaped
4441 $headlineHint = $safeHeadline;
4442
4443 # Decode HTML entities
4444 $safeHeadline = Sanitizer::decodeCharReferences( $safeHeadline );
4445
4446 $safeHeadline = self::normalizeSectionName( $safeHeadline );
4447
4448 $fallbackHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_FALLBACK );
4449 $linkAnchor = Sanitizer::escapeIdForLink( $safeHeadline );
4450 $safeHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_PRIMARY );
4451 if ( $fallbackHeadline === $safeHeadline ) {
4452 # No reason to have both (in fact, we can't)
4453 $fallbackHeadline = false;
4454 }
4455
4456 # HTML IDs must be case-insensitively unique for IE compatibility (T12721).
4457 # @todo FIXME: We may be changing them depending on the current locale.
4458 $arrayKey = strtolower( $safeHeadline );
4459 if ( $fallbackHeadline === false ) {
4460 $fallbackArrayKey = false;
4461 } else {
4462 $fallbackArrayKey = strtolower( $fallbackHeadline );
4463 }
4464
4465 # Create the anchor for linking from the TOC to the section
4466 $anchor = $safeHeadline;
4467 $fallbackAnchor = $fallbackHeadline;
4468 if ( isset( $refers[$arrayKey] ) ) {
4469 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4470 for ( $i = 2; isset( $refers["${arrayKey}_$i"] ); ++$i );
4471 $anchor .= "_$i";
4472 $linkAnchor .= "_$i";
4473 $refers["${arrayKey}_$i"] = true;
4474 } else {
4475 $refers[$arrayKey] = true;
4476 }
4477 if ( $fallbackHeadline !== false && isset( $refers[$fallbackArrayKey] ) ) {
4478 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4479 for ( $i = 2; isset( $refers["${fallbackArrayKey}_$i"] ); ++$i );
4480 $fallbackAnchor .= "_$i";
4481 $refers["${fallbackArrayKey}_$i"] = true;
4482 } else {
4483 $refers[$fallbackArrayKey] = true;
4484 }
4485
4486 # Don't number the heading if it is the only one (looks silly)
4487 if ( count( $matches[3] ) > 1 && $this->mOptions->getNumberHeadings() ) {
4488 # the two are different if the line contains a link
4489 $headline = Html::element(
4490 'span',
4491 [ 'class' => 'mw-headline-number' ],
4492 $numbering
4493 ) . ' ' . $headline;
4494 }
4495
4496 if ( $enoughToc && ( !isset( $maxTocLevel ) || $toclevel < $maxTocLevel ) ) {
4497 $toc .= Linker::tocLine( $linkAnchor, $tocline,
4498 $numbering, $toclevel, ( $isTemplate ? false : $sectionIndex ) );
4499 }
4500
4501 # Add the section to the section tree
4502 # Find the DOM node for this header
4503 $noOffset = ( $isTemplate || $sectionIndex === false );
4504 while ( $node && !$noOffset ) {
4505 if ( $node->getName() === 'h' ) {
4506 $bits = $node->splitHeading();
4507 if ( $bits['i'] == $sectionIndex ) {
4508 break;
4509 }
4510 }
4511 $byteOffset += mb_strlen( $this->mStripState->unstripBoth(
4512 $frame->expand( $node, PPFrame::RECOVER_ORIG ) ) );
4513 $node = $node->getNextSibling();
4514 }
4515 $tocraw[] = [
4516 'toclevel' => $toclevel,
4517 'level' => $level,
4518 'line' => $tocline,
4519 'number' => $numbering,
4520 'index' => ( $isTemplate ? 'T-' : '' ) . $sectionIndex,
4521 'fromtitle' => $titleText,
4522 'byteoffset' => ( $noOffset ? null : $byteOffset ),
4523 'anchor' => $anchor,
4524 ];
4525
4526 # give headline the correct <h#> tag
4527 if ( $maybeShowEditLink && $sectionIndex !== false ) {
4528 // Output edit section links as markers with styles that can be customized by skins
4529 if ( $isTemplate ) {
4530 # Put a T flag in the section identifier, to indicate to extractSections()
4531 # that sections inside <includeonly> should be counted.
4532 $editsectionPage = $titleText;
4533 $editsectionSection = "T-$sectionIndex";
4534 $editsectionContent = null;
4535 } else {
4536 $editsectionPage = $this->mTitle->getPrefixedText();
4537 $editsectionSection = $sectionIndex;
4538 $editsectionContent = $headlineHint;
4539 }
4540 // We use a bit of pesudo-xml for editsection markers. The
4541 // language converter is run later on. Using a UNIQ style marker
4542 // leads to the converter screwing up the tokens when it
4543 // converts stuff. And trying to insert strip tags fails too. At
4544 // this point all real inputted tags have already been escaped,
4545 // so we don't have to worry about a user trying to input one of
4546 // these markers directly. We use a page and section attribute
4547 // to stop the language converter from converting these
4548 // important bits of data, but put the headline hint inside a
4549 // content block because the language converter is supposed to
4550 // be able to convert that piece of data.
4551 // Gets replaced with html in ParserOutput::getText
4552 $editlink = '<mw:editsection page="' . htmlspecialchars( $editsectionPage );
4553 $editlink .= '" section="' . htmlspecialchars( $editsectionSection ) . '"';
4554 if ( $editsectionContent !== null ) {
4555 $editlink .= '>' . $editsectionContent . '</mw:editsection>';
4556 } else {
4557 $editlink .= '/>';
4558 }
4559 } else {
4560 $editlink = '';
4561 }
4562 $head[$headlineCount] = Linker::makeHeadline( $level,
4563 $matches['attrib'][$headlineCount], $anchor, $headline,
4564 $editlink, $fallbackAnchor );
4565
4566 $headlineCount++;
4567 }
4568
4569 $this->setOutputType( $oldType );
4570
4571 # Never ever show TOC if no headers
4572 if ( $numVisible < 1 ) {
4573 $enoughToc = false;
4574 }
4575
4576 if ( $enoughToc ) {
4577 if ( $prevtoclevel > 0 && $prevtoclevel < $maxTocLevel ) {
4578 $toc .= Linker::tocUnindent( $prevtoclevel - 1 );
4579 }
4580 $toc = Linker::tocList( $toc, $this->mOptions->getUserLangObj() );
4581 $this->mOutput->setTOCHTML( $toc );
4582 $toc = self::TOC_START . $toc . self::TOC_END;
4583 }
4584
4585 if ( $isMain ) {
4586 $this->mOutput->setSections( $tocraw );
4587 }
4588
4589 # split up and insert constructed headlines
4590 $blocks = preg_split( '/<H[1-6].*?>[\s\S]*?<\/H[1-6]>/i', $text );
4591 $i = 0;
4592
4593 // build an array of document sections
4594 $sections = [];
4595 foreach ( $blocks as $block ) {
4596 // $head is zero-based, sections aren't.
4597 if ( empty( $head[$i - 1] ) ) {
4598 $sections[$i] = $block;
4599 } else {
4600 $sections[$i] = $head[$i - 1] . $block;
4601 }
4602
4603 /**
4604 * Send a hook, one per section.
4605 * The idea here is to be able to make section-level DIVs, but to do so in a
4606 * lower-impact, more correct way than r50769
4607 *
4608 * $this : caller
4609 * $section : the section number
4610 * &$sectionContent : ref to the content of the section
4611 * $maybeShowEditLinks : boolean describing whether this section has an edit link
4612 */
4613 Hooks::run( 'ParserSectionCreate', [ $this, $i, &$sections[$i], $maybeShowEditLink ] );
4614
4615 $i++;
4616 }
4617
4618 if ( $enoughToc && $isMain && !$this->mForceTocPosition ) {
4619 // append the TOC at the beginning
4620 // Top anchor now in skin
4621 $sections[0] .= $toc . "\n";
4622 }
4623
4624 $full .= implode( '', $sections );
4625
4626 if ( $this->mForceTocPosition ) {
4627 return str_replace( '<!--MWTOC\'"-->', $toc, $full );
4628 } else {
4629 return $full;
4630 }
4631 }
4632
4633 /**
4634 * Transform wiki markup when saving a page by doing "\r\n" -> "\n"
4635 * conversion, substituting signatures, {{subst:}} templates, etc.
4636 *
4637 * @param string $text The text to transform
4638 * @param Title $title The Title object for the current article
4639 * @param User $user The User object describing the current user
4640 * @param ParserOptions $options Parsing options
4641 * @param bool $clearState Whether to clear the parser state first
4642 * @return string The altered wiki markup
4643 */
4644 public function preSaveTransform( $text, Title $title, User $user,
4645 ParserOptions $options, $clearState = true
4646 ) {
4647 if ( $clearState ) {
4648 $magicScopeVariable = $this->lock();
4649 }
4650 $this->startParse( $title, $options, self::OT_WIKI, $clearState );
4651 $this->setUser( $user );
4652
4653 // Strip U+0000 NULL (T159174)
4654 $text = str_replace( "\000", '', $text );
4655
4656 // We still normalize line endings for backwards-compatibility
4657 // with other code that just calls PST, but this should already
4658 // be handled in TextContent subclasses
4659 $text = TextContent::normalizeLineEndings( $text );
4660
4661 if ( $options->getPreSaveTransform() ) {
4662 $text = $this->pstPass2( $text, $user );
4663 }
4664 $text = $this->mStripState->unstripBoth( $text );
4665
4666 $this->setUser( null ); # Reset
4667
4668 return $text;
4669 }
4670
4671 /**
4672 * Pre-save transform helper function
4673 *
4674 * @param string $text
4675 * @param User $user
4676 *
4677 * @return string
4678 */
4679 private function pstPass2( $text, $user ) {
4680 # Note: This is the timestamp saved as hardcoded wikitext to the database, we use
4681 # $this->contLang here in order to give everyone the same signature and use the default one
4682 # rather than the one selected in each user's preferences. (see also T14815)
4683 $ts = $this->mOptions->getTimestamp();
4684 $timestamp = MWTimestamp::getLocalInstance( $ts );
4685 $ts = $timestamp->format( 'YmdHis' );
4686 $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
4687
4688 $d = $this->contLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
4689
4690 # Variable replacement
4691 # Because mOutputType is OT_WIKI, this will only process {{subst:xxx}} type tags
4692 $text = $this->replaceVariables( $text );
4693
4694 # This works almost by chance, as the replaceVariables are done before the getUserSig(),
4695 # which may corrupt this parser instance via its wfMessage()->text() call-
4696
4697 # Signatures
4698 if ( strpos( $text, '~~~' ) !== false ) {
4699 $sigText = $this->getUserSig( $user );
4700 $text = strtr( $text, [
4701 '~~~~~' => $d,
4702 '~~~~' => "$sigText $d",
4703 '~~~' => $sigText
4704 ] );
4705 # The main two signature forms used above are time-sensitive
4706 $this->setOutputFlag( 'user-signature', 'User signature detected' );
4707 }
4708
4709 # Context links ("pipe tricks"): [[|name]] and [[name (context)|]]
4710 $tc = '[' . Title::legalChars() . ']';
4711 $nc = '[ _0-9A-Za-z\x80-\xff-]'; # Namespaces can use non-ascii!
4712
4713 // [[ns:page (context)|]]
4714 $p1 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\))\\|]]/";
4715 // [[ns:page(context)|]] (double-width brackets, added in r40257)
4716 $p4 = "/\[\[(:?$nc+:|:|)($tc+?)( ?($tc+))\\|]]/";
4717 // [[ns:page (context), context|]] (using either single or double-width comma)
4718 $p3 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\)|)((?:, |,)$tc+|)\\|]]/";
4719 // [[|page]] (reverse pipe trick: add context from page title)
4720 $p2 = "/\[\[\\|($tc+)]]/";
4721
4722 # try $p1 first, to turn "[[A, B (C)|]]" into "[[A, B (C)|A, B]]"
4723 $text = preg_replace( $p1, '[[\\1\\2\\3|\\2]]', $text );
4724 $text = preg_replace( $p4, '[[\\1\\2\\3|\\2]]', $text );
4725 $text = preg_replace( $p3, '[[\\1\\2\\3\\4|\\2]]', $text );
4726
4727 $t = $this->mTitle->getText();
4728 $m = [];
4729 if ( preg_match( "/^($nc+:|)$tc+?( \\($tc+\\))$/", $t, $m ) ) {
4730 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4731 } elseif ( preg_match( "/^($nc+:|)$tc+?(, $tc+|)$/", $t, $m ) && "$m[1]$m[2]" != '' ) {
4732 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4733 } else {
4734 # if there's no context, don't bother duplicating the title
4735 $text = preg_replace( $p2, '[[\\1]]', $text );
4736 }
4737
4738 return $text;
4739 }
4740
4741 /**
4742 * Fetch the user's signature text, if any, and normalize to
4743 * validated, ready-to-insert wikitext.
4744 * If you have pre-fetched the nickname or the fancySig option, you can
4745 * specify them here to save a database query.
4746 * Do not reuse this parser instance after calling getUserSig(),
4747 * as it may have changed.
4748 *
4749 * @param User &$user
4750 * @param string|bool $nickname Nickname to use or false to use user's default nickname
4751 * @param bool|null $fancySig whether the nicknname is the complete signature
4752 * or null to use default value
4753 * @return string
4754 */
4755 public function getUserSig( &$user, $nickname = false, $fancySig = null ) {
4756 $username = $user->getName();
4757
4758 # If not given, retrieve from the user object.
4759 if ( $nickname === false ) {
4760 $nickname = $user->getOption( 'nickname' );
4761 }
4762
4763 if ( is_null( $fancySig ) ) {
4764 $fancySig = $user->getBoolOption( 'fancysig' );
4765 }
4766
4767 $nickname = $nickname == null ? $username : $nickname;
4768
4769 if ( mb_strlen( $nickname ) > $this->svcOptions->get( 'MaxSigChars' ) ) {
4770 $nickname = $username;
4771 $this->logger->debug( __METHOD__ . ": $username has overlong signature." );
4772 } elseif ( $fancySig !== false ) {
4773 # Sig. might contain markup; validate this
4774 if ( $this->validateSig( $nickname ) !== false ) {
4775 # Validated; clean up (if needed) and return it
4776 return $this->cleanSig( $nickname, true );
4777 } else {
4778 # Failed to validate; fall back to the default
4779 $nickname = $username;
4780 $this->logger->debug( __METHOD__ . ": $username has bad XML tags in signature." );
4781 }
4782 }
4783
4784 # Make sure nickname doesnt get a sig in a sig
4785 $nickname = self::cleanSigInSig( $nickname );
4786
4787 # If we're still here, make it a link to the user page
4788 $userText = wfEscapeWikiText( $username );
4789 $nickText = wfEscapeWikiText( $nickname );
4790 $msgName = $user->isAnon() ? 'signature-anon' : 'signature';
4791
4792 return wfMessage( $msgName, $userText, $nickText )->inContentLanguage()
4793 ->title( $this->getTitle() )->text();
4794 }
4795
4796 /**
4797 * Check that the user's signature contains no bad XML
4798 *
4799 * @param string $text
4800 * @return string|bool An expanded string, or false if invalid.
4801 */
4802 public function validateSig( $text ) {
4803 return Xml::isWellFormedXmlFragment( $text ) ? $text : false;
4804 }
4805
4806 /**
4807 * Clean up signature text
4808 *
4809 * 1) Strip 3, 4 or 5 tildes out of signatures @see cleanSigInSig
4810 * 2) Substitute all transclusions
4811 *
4812 * @param string $text
4813 * @param bool $parsing Whether we're cleaning (preferences save) or parsing
4814 * @return string Signature text
4815 */
4816 public function cleanSig( $text, $parsing = false ) {
4817 if ( !$parsing ) {
4818 global $wgTitle;
4819 $magicScopeVariable = $this->lock();
4820 $this->startParse( $wgTitle, new ParserOptions, self::OT_PREPROCESS, true );
4821 }
4822
4823 # Option to disable this feature
4824 if ( !$this->mOptions->getCleanSignatures() ) {
4825 return $text;
4826 }
4827
4828 # @todo FIXME: Regex doesn't respect extension tags or nowiki
4829 # => Move this logic to braceSubstitution()
4830 $substWord = $this->magicWordFactory->get( 'subst' );
4831 $substRegex = '/\{\{(?!(?:' . $substWord->getBaseRegex() . '))/x' . $substWord->getRegexCase();
4832 $substText = '{{' . $substWord->getSynonym( 0 );
4833
4834 $text = preg_replace( $substRegex, $substText, $text );
4835 $text = self::cleanSigInSig( $text );
4836 $dom = $this->preprocessToDom( $text );
4837 $frame = $this->getPreprocessor()->newFrame();
4838 $text = $frame->expand( $dom );
4839
4840 if ( !$parsing ) {
4841 $text = $this->mStripState->unstripBoth( $text );
4842 }
4843
4844 return $text;
4845 }
4846
4847 /**
4848 * Strip 3, 4 or 5 tildes out of signatures.
4849 *
4850 * @param string $text
4851 * @return string Signature text with /~{3,5}/ removed
4852 */
4853 public static function cleanSigInSig( $text ) {
4854 $text = preg_replace( '/~{3,5}/', '', $text );
4855 return $text;
4856 }
4857
4858 /**
4859 * Set up some variables which are usually set up in parse()
4860 * so that an external function can call some class members with confidence
4861 *
4862 * @param Title|null $title
4863 * @param ParserOptions $options
4864 * @param int $outputType
4865 * @param bool $clearState
4866 */
4867 public function startExternalParse( Title $title = null, ParserOptions $options,
4868 $outputType, $clearState = true
4869 ) {
4870 $this->startParse( $title, $options, $outputType, $clearState );
4871 }
4872
4873 /**
4874 * @param Title|null $title
4875 * @param ParserOptions $options
4876 * @param int $outputType
4877 * @param bool $clearState
4878 */
4879 private function startParse( Title $title = null, ParserOptions $options,
4880 $outputType, $clearState = true
4881 ) {
4882 $this->setTitle( $title );
4883 $this->mOptions = $options;
4884 $this->setOutputType( $outputType );
4885 if ( $clearState ) {
4886 $this->clearState();
4887 }
4888 }
4889
4890 /**
4891 * Wrapper for preprocess()
4892 *
4893 * @param string $text The text to preprocess
4894 * @param ParserOptions $options
4895 * @param Title|null $title Title object or null to use $wgTitle
4896 * @return string
4897 */
4898 public function transformMsg( $text, $options, $title = null ) {
4899 static $executing = false;
4900
4901 # Guard against infinite recursion
4902 if ( $executing ) {
4903 return $text;
4904 }
4905 $executing = true;
4906
4907 if ( !$title ) {
4908 global $wgTitle;
4909 $title = $wgTitle;
4910 }
4911
4912 $text = $this->preprocess( $text, $title, $options );
4913
4914 $executing = false;
4915 return $text;
4916 }
4917
4918 /**
4919 * Create an HTML-style tag, e.g. "<yourtag>special text</yourtag>"
4920 * The callback should have the following form:
4921 * function myParserHook( $text, $params, $parser, $frame ) { ... }
4922 *
4923 * Transform and return $text. Use $parser for any required context, e.g. use
4924 * $parser->getTitle() and $parser->getOptions() not $wgTitle or $wgOut->mParserOptions
4925 *
4926 * Hooks may return extended information by returning an array, of which the
4927 * first numbered element (index 0) must be the return string, and all other
4928 * entries are extracted into local variables within an internal function
4929 * in the Parser class.
4930 *
4931 * This interface (introduced r61913) appears to be undocumented, but
4932 * 'markerType' is used by some core tag hooks to override which strip
4933 * array their results are placed in. **Use great caution if attempting
4934 * this interface, as it is not documented and injudicious use could smash
4935 * private variables.**
4936 *
4937 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4938 * @param callable $callback The callback function (and object) to use for the tag
4939 * @throws MWException
4940 * @return callable|null The old value of the mTagHooks array associated with the hook
4941 */
4942 public function setHook( $tag, callable $callback ) {
4943 $tag = strtolower( $tag );
4944 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4945 throw new MWException( "Invalid character {$m[0]} in setHook('$tag', ...) call" );
4946 }
4947 $oldVal = $this->mTagHooks[$tag] ?? null;
4948 $this->mTagHooks[$tag] = $callback;
4949 if ( !in_array( $tag, $this->mStripList ) ) {
4950 $this->mStripList[] = $tag;
4951 }
4952
4953 return $oldVal;
4954 }
4955
4956 /**
4957 * As setHook(), but letting the contents be parsed.
4958 *
4959 * Transparent tag hooks are like regular XML-style tag hooks, except they
4960 * operate late in the transformation sequence, on HTML instead of wikitext.
4961 *
4962 * This is probably obsoleted by things dealing with parser frames?
4963 * The only extension currently using it is geoserver.
4964 *
4965 * @since 1.10
4966 * @todo better document or deprecate this
4967 *
4968 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4969 * @param callable $callback The callback function (and object) to use for the tag
4970 * @throws MWException
4971 * @return callable|null The old value of the mTagHooks array associated with the hook
4972 */
4973 public function setTransparentTagHook( $tag, callable $callback ) {
4974 $tag = strtolower( $tag );
4975 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4976 throw new MWException( "Invalid character {$m[0]} in setTransparentHook('$tag', ...) call" );
4977 }
4978 $oldVal = $this->mTransparentTagHooks[$tag] ?? null;
4979 $this->mTransparentTagHooks[$tag] = $callback;
4980
4981 return $oldVal;
4982 }
4983
4984 /**
4985 * Remove all tag hooks
4986 */
4987 public function clearTagHooks() {
4988 $this->mTagHooks = [];
4989 $this->mFunctionTagHooks = [];
4990 $this->mStripList = $this->mDefaultStripList;
4991 }
4992
4993 /**
4994 * Create a function, e.g. {{sum:1|2|3}}
4995 * The callback function should have the form:
4996 * function myParserFunction( &$parser, $arg1, $arg2, $arg3 ) { ... }
4997 *
4998 * Or with Parser::SFH_OBJECT_ARGS:
4999 * function myParserFunction( $parser, $frame, $args ) { ... }
5000 *
5001 * The callback may either return the text result of the function, or an array with the text
5002 * in element 0, and a number of flags in the other elements. The names of the flags are
5003 * specified in the keys. Valid flags are:
5004 * found The text returned is valid, stop processing the template. This
5005 * is on by default.
5006 * nowiki Wiki markup in the return value should be escaped
5007 * isHTML The returned text is HTML, armour it against wikitext transformation
5008 *
5009 * @param string $id The magic word ID
5010 * @param callable $callback The callback function (and object) to use
5011 * @param int $flags A combination of the following flags:
5012 * Parser::SFH_NO_HASH No leading hash, i.e. {{plural:...}} instead of {{#if:...}}
5013 *
5014 * Parser::SFH_OBJECT_ARGS Pass the template arguments as PPNode objects instead of text.
5015 * This allows for conditional expansion of the parse tree, allowing you to eliminate dead
5016 * branches and thus speed up parsing. It is also possible to analyse the parse tree of
5017 * the arguments, and to control the way they are expanded.
5018 *
5019 * The $frame parameter is a PPFrame. This can be used to produce expanded text from the
5020 * arguments, for instance:
5021 * $text = isset( $args[0] ) ? $frame->expand( $args[0] ) : '';
5022 *
5023 * For technical reasons, $args[0] is pre-expanded and will be a string. This may change in
5024 * future versions. Please call $frame->expand() on it anyway so that your code keeps
5025 * working if/when this is changed.
5026 *
5027 * If you want whitespace to be trimmed from $args, you need to do it yourself, post-
5028 * expansion.
5029 *
5030 * Please read the documentation in includes/parser/Preprocessor.php for more information
5031 * about the methods available in PPFrame and PPNode.
5032 *
5033 * @throws MWException
5034 * @return string|callable The old callback function for this name, if any
5035 */
5036 public function setFunctionHook( $id, callable $callback, $flags = 0 ) {
5037 $oldVal = isset( $this->mFunctionHooks[$id] ) ? $this->mFunctionHooks[$id][0] : null;
5038 $this->mFunctionHooks[$id] = [ $callback, $flags ];
5039
5040 # Add to function cache
5041 $mw = $this->magicWordFactory->get( $id );
5042 if ( !$mw ) {
5043 throw new MWException( __METHOD__ . '() expecting a magic word identifier.' );
5044 }
5045
5046 $synonyms = $mw->getSynonyms();
5047 $sensitive = intval( $mw->isCaseSensitive() );
5048
5049 foreach ( $synonyms as $syn ) {
5050 # Case
5051 if ( !$sensitive ) {
5052 $syn = $this->contLang->lc( $syn );
5053 }
5054 # Add leading hash
5055 if ( !( $flags & self::SFH_NO_HASH ) ) {
5056 $syn = '#' . $syn;
5057 }
5058 # Remove trailing colon
5059 if ( substr( $syn, -1, 1 ) === ':' ) {
5060 $syn = substr( $syn, 0, -1 );
5061 }
5062 $this->mFunctionSynonyms[$sensitive][$syn] = $id;
5063 }
5064 return $oldVal;
5065 }
5066
5067 /**
5068 * Get all registered function hook identifiers
5069 *
5070 * @return array
5071 */
5072 public function getFunctionHooks() {
5073 $this->firstCallInit();
5074 return array_keys( $this->mFunctionHooks );
5075 }
5076
5077 /**
5078 * Create a tag function, e.g. "<test>some stuff</test>".
5079 * Unlike tag hooks, tag functions are parsed at preprocessor level.
5080 * Unlike parser functions, their content is not preprocessed.
5081 * @param string $tag
5082 * @param callable $callback
5083 * @param int $flags
5084 * @throws MWException
5085 * @return null
5086 */
5087 public function setFunctionTagHook( $tag, callable $callback, $flags ) {
5088 $tag = strtolower( $tag );
5089 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
5090 throw new MWException( "Invalid character {$m[0]} in setFunctionTagHook('$tag', ...) call" );
5091 }
5092 $old = $this->mFunctionTagHooks[$tag] ?? null;
5093 $this->mFunctionTagHooks[$tag] = [ $callback, $flags ];
5094
5095 if ( !in_array( $tag, $this->mStripList ) ) {
5096 $this->mStripList[] = $tag;
5097 }
5098
5099 return $old;
5100 }
5101
5102 /**
5103 * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
5104 * Placeholders created in Linker::link()
5105 *
5106 * @param string &$text
5107 * @param int $options
5108 */
5109 public function replaceLinkHolders( &$text, $options = 0 ) {
5110 $this->mLinkHolders->replace( $text );
5111 }
5112
5113 /**
5114 * Replace "<!--LINK-->" link placeholders with plain text of links
5115 * (not HTML-formatted).
5116 *
5117 * @param string $text
5118 * @return string
5119 */
5120 public function replaceLinkHoldersText( $text ) {
5121 return $this->mLinkHolders->replaceText( $text );
5122 }
5123
5124 /**
5125 * Renders an image gallery from a text with one line per image.
5126 * text labels may be given by using |-style alternative text. E.g.
5127 * Image:one.jpg|The number "1"
5128 * Image:tree.jpg|A tree
5129 * given as text will return the HTML of a gallery with two images,
5130 * labeled 'The number "1"' and
5131 * 'A tree'.
5132 *
5133 * @param string $text
5134 * @param array $params
5135 * @return string HTML
5136 */
5137 public function renderImageGallery( $text, $params ) {
5138 $mode = false;
5139 if ( isset( $params['mode'] ) ) {
5140 $mode = $params['mode'];
5141 }
5142
5143 try {
5144 $ig = ImageGalleryBase::factory( $mode );
5145 } catch ( Exception $e ) {
5146 // If invalid type set, fallback to default.
5147 $ig = ImageGalleryBase::factory( false );
5148 }
5149
5150 $ig->setContextTitle( $this->mTitle );
5151 $ig->setShowBytes( false );
5152 $ig->setShowDimensions( false );
5153 $ig->setShowFilename( false );
5154 $ig->setParser( $this );
5155 $ig->setHideBadImages();
5156 $ig->setAttributes( Sanitizer::validateTagAttributes( $params, 'ul' ) );
5157
5158 if ( isset( $params['showfilename'] ) ) {
5159 $ig->setShowFilename( true );
5160 } else {
5161 $ig->setShowFilename( false );
5162 }
5163 if ( isset( $params['caption'] ) ) {
5164 // NOTE: We aren't passing a frame here or below. Frame info
5165 // is currently opaque to Parsoid, which acts on OT_PREPROCESS.
5166 // See T107332#4030581
5167 $caption = $this->recursiveTagParse( $params['caption'] );
5168 $ig->setCaptionHtml( $caption );
5169 }
5170 if ( isset( $params['perrow'] ) ) {
5171 $ig->setPerRow( $params['perrow'] );
5172 }
5173 if ( isset( $params['widths'] ) ) {
5174 $ig->setWidths( $params['widths'] );
5175 }
5176 if ( isset( $params['heights'] ) ) {
5177 $ig->setHeights( $params['heights'] );
5178 }
5179 $ig->setAdditionalOptions( $params );
5180
5181 // Avoid PHP 7.1 warning from passing $this by reference
5182 $parser = $this;
5183 Hooks::run( 'BeforeParserrenderImageGallery', [ &$parser, &$ig ] );
5184
5185 $lines = StringUtils::explode( "\n", $text );
5186 foreach ( $lines as $line ) {
5187 # match lines like these:
5188 # Image:someimage.jpg|This is some image
5189 $matches = [];
5190 preg_match( "/^([^|]+)(\\|(.*))?$/", $line, $matches );
5191 # Skip empty lines
5192 if ( count( $matches ) == 0 ) {
5193 continue;
5194 }
5195
5196 if ( strpos( $matches[0], '%' ) !== false ) {
5197 $matches[1] = rawurldecode( $matches[1] );
5198 }
5199 $title = Title::newFromText( $matches[1], NS_FILE );
5200 if ( is_null( $title ) ) {
5201 # Bogus title. Ignore these so we don't bomb out later.
5202 continue;
5203 }
5204
5205 # We need to get what handler the file uses, to figure out parameters.
5206 # Note, a hook can overide the file name, and chose an entirely different
5207 # file (which potentially could be of a different type and have different handler).
5208 $options = [];
5209 $descQuery = false;
5210 Hooks::run( 'BeforeParserFetchFileAndTitle',
5211 [ $this, $title, &$options, &$descQuery ] );
5212 # Don't register it now, as TraditionalImageGallery does that later.
5213 $file = $this->fetchFileNoRegister( $title, $options );
5214 $handler = $file ? $file->getHandler() : false;
5215
5216 $paramMap = [
5217 'img_alt' => 'gallery-internal-alt',
5218 'img_link' => 'gallery-internal-link',
5219 ];
5220 if ( $handler ) {
5221 $paramMap += $handler->getParamMap();
5222 // We don't want people to specify per-image widths.
5223 // Additionally the width parameter would need special casing anyhow.
5224 unset( $paramMap['img_width'] );
5225 }
5226
5227 $mwArray = $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5228
5229 $label = '';
5230 $alt = '';
5231 $link = '';
5232 $handlerOptions = [];
5233 if ( isset( $matches[3] ) ) {
5234 // look for an |alt= definition while trying not to break existing
5235 // captions with multiple pipes (|) in it, until a more sensible grammar
5236 // is defined for images in galleries
5237
5238 // FIXME: Doing recursiveTagParse at this stage, and the trim before
5239 // splitting on '|' is a bit odd, and different from makeImage.
5240 $matches[3] = $this->recursiveTagParse( trim( $matches[3] ) );
5241 // Protect LanguageConverter markup
5242 $parameterMatches = StringUtils::delimiterExplode(
5243 '-{', '}-', '|', $matches[3], true /* nested */
5244 );
5245
5246 foreach ( $parameterMatches as $parameterMatch ) {
5247 list( $magicName, $match ) = $mwArray->matchVariableStartToEnd( $parameterMatch );
5248 if ( $magicName ) {
5249 $paramName = $paramMap[$magicName];
5250
5251 switch ( $paramName ) {
5252 case 'gallery-internal-alt':
5253 $alt = $this->stripAltText( $match, false );
5254 break;
5255 case 'gallery-internal-link':
5256 $linkValue = $this->stripAltText( $match, false );
5257 if ( preg_match( '/^-{R|(.*)}-$/', $linkValue ) ) {
5258 // Result of LanguageConverter::markNoConversion
5259 // invoked on an external link.
5260 $linkValue = substr( $linkValue, 4, -2 );
5261 }
5262 list( $type, $target ) = $this->parseLinkParameter( $linkValue );
5263 if ( $type === 'link-url' ) {
5264 $link = $target;
5265 $this->mOutput->addExternalLink( $target );
5266 } elseif ( $type === 'link-title' ) {
5267 $link = $target->getLinkURL();
5268 $this->mOutput->addLink( $target );
5269 }
5270 break;
5271 default:
5272 // Must be a handler specific parameter.
5273 if ( $handler->validateParam( $paramName, $match ) ) {
5274 $handlerOptions[$paramName] = $match;
5275 } else {
5276 // Guess not, consider it as caption.
5277 $this->logger->debug(
5278 "$parameterMatch failed parameter validation" );
5279 $label = $parameterMatch;
5280 }
5281 }
5282
5283 } else {
5284 // Last pipe wins.
5285 $label = $parameterMatch;
5286 }
5287 }
5288 }
5289
5290 $ig->add( $title, $label, $alt, $link, $handlerOptions );
5291 }
5292 $html = $ig->toHTML();
5293 Hooks::run( 'AfterParserFetchFileAndTitle', [ $this, $ig, &$html ] );
5294 return $html;
5295 }
5296
5297 /**
5298 * @param MediaHandler $handler
5299 * @return array
5300 */
5301 public function getImageParams( $handler ) {
5302 if ( $handler ) {
5303 $handlerClass = get_class( $handler );
5304 } else {
5305 $handlerClass = '';
5306 }
5307 if ( !isset( $this->mImageParams[$handlerClass] ) ) {
5308 # Initialise static lists
5309 static $internalParamNames = [
5310 'horizAlign' => [ 'left', 'right', 'center', 'none' ],
5311 'vertAlign' => [ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle',
5312 'bottom', 'text-bottom' ],
5313 'frame' => [ 'thumbnail', 'manualthumb', 'framed', 'frameless',
5314 'upright', 'border', 'link', 'alt', 'class' ],
5315 ];
5316 static $internalParamMap;
5317 if ( !$internalParamMap ) {
5318 $internalParamMap = [];
5319 foreach ( $internalParamNames as $type => $names ) {
5320 foreach ( $names as $name ) {
5321 // For grep: img_left, img_right, img_center, img_none,
5322 // img_baseline, img_sub, img_super, img_top, img_text_top, img_middle,
5323 // img_bottom, img_text_bottom,
5324 // img_thumbnail, img_manualthumb, img_framed, img_frameless, img_upright,
5325 // img_border, img_link, img_alt, img_class
5326 $magicName = str_replace( '-', '_', "img_$name" );
5327 $internalParamMap[$magicName] = [ $type, $name ];
5328 }
5329 }
5330 }
5331
5332 # Add handler params
5333 $paramMap = $internalParamMap;
5334 if ( $handler ) {
5335 $handlerParamMap = $handler->getParamMap();
5336 foreach ( $handlerParamMap as $magic => $paramName ) {
5337 $paramMap[$magic] = [ 'handler', $paramName ];
5338 }
5339 }
5340 $this->mImageParams[$handlerClass] = $paramMap;
5341 $this->mImageParamsMagicArray[$handlerClass] =
5342 $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5343 }
5344 return [ $this->mImageParams[$handlerClass], $this->mImageParamsMagicArray[$handlerClass] ];
5345 }
5346
5347 /**
5348 * Parse image options text and use it to make an image
5349 *
5350 * @param Title $title
5351 * @param string $options
5352 * @param LinkHolderArray|bool $holders
5353 * @return string HTML
5354 */
5355 public function makeImage( $title, $options, $holders = false ) {
5356 # Check if the options text is of the form "options|alt text"
5357 # Options are:
5358 # * thumbnail make a thumbnail with enlarge-icon and caption, alignment depends on lang
5359 # * left no resizing, just left align. label is used for alt= only
5360 # * right same, but right aligned
5361 # * none same, but not aligned
5362 # * ___px scale to ___ pixels width, no aligning. e.g. use in taxobox
5363 # * center center the image
5364 # * frame Keep original image size, no magnify-button.
5365 # * framed Same as "frame"
5366 # * frameless like 'thumb' but without a frame. Keeps user preferences for width
5367 # * upright reduce width for upright images, rounded to full __0 px
5368 # * border draw a 1px border around the image
5369 # * alt Text for HTML alt attribute (defaults to empty)
5370 # * class Set a class for img node
5371 # * link Set the target of the image link. Can be external, interwiki, or local
5372 # vertical-align values (no % or length right now):
5373 # * baseline
5374 # * sub
5375 # * super
5376 # * top
5377 # * text-top
5378 # * middle
5379 # * bottom
5380 # * text-bottom
5381
5382 # Protect LanguageConverter markup when splitting into parts
5383 $parts = StringUtils::delimiterExplode(
5384 '-{', '}-', '|', $options, true /* allow nesting */
5385 );
5386
5387 # Give extensions a chance to select the file revision for us
5388 $options = [];
5389 $descQuery = false;
5390 Hooks::run( 'BeforeParserFetchFileAndTitle',
5391 [ $this, $title, &$options, &$descQuery ] );
5392 # Fetch and register the file (file title may be different via hooks)
5393 list( $file, $title ) = $this->fetchFileAndTitle( $title, $options );
5394
5395 # Get parameter map
5396 $handler = $file ? $file->getHandler() : false;
5397
5398 list( $paramMap, $mwArray ) = $this->getImageParams( $handler );
5399
5400 if ( !$file ) {
5401 $this->addTrackingCategory( 'broken-file-category' );
5402 }
5403
5404 # Process the input parameters
5405 $caption = '';
5406 $params = [ 'frame' => [], 'handler' => [],
5407 'horizAlign' => [], 'vertAlign' => [] ];
5408 $seenformat = false;
5409 foreach ( $parts as $part ) {
5410 $part = trim( $part );
5411 list( $magicName, $value ) = $mwArray->matchVariableStartToEnd( $part );
5412 $validated = false;
5413 if ( isset( $paramMap[$magicName] ) ) {
5414 list( $type, $paramName ) = $paramMap[$magicName];
5415
5416 # Special case; width and height come in one variable together
5417 if ( $type === 'handler' && $paramName === 'width' ) {
5418 $parsedWidthParam = self::parseWidthParam( $value );
5419 if ( isset( $parsedWidthParam['width'] ) ) {
5420 $width = $parsedWidthParam['width'];
5421 if ( $handler->validateParam( 'width', $width ) ) {
5422 $params[$type]['width'] = $width;
5423 $validated = true;
5424 }
5425 }
5426 if ( isset( $parsedWidthParam['height'] ) ) {
5427 $height = $parsedWidthParam['height'];
5428 if ( $handler->validateParam( 'height', $height ) ) {
5429 $params[$type]['height'] = $height;
5430 $validated = true;
5431 }
5432 }
5433 # else no validation -- T15436
5434 } else {
5435 if ( $type === 'handler' ) {
5436 # Validate handler parameter
5437 $validated = $handler->validateParam( $paramName, $value );
5438 } else {
5439 # Validate internal parameters
5440 switch ( $paramName ) {
5441 case 'manualthumb':
5442 case 'alt':
5443 case 'class':
5444 # @todo FIXME: Possibly check validity here for
5445 # manualthumb? downstream behavior seems odd with
5446 # missing manual thumbs.
5447 $validated = true;
5448 $value = $this->stripAltText( $value, $holders );
5449 break;
5450 case 'link':
5451 list( $paramName, $value ) =
5452 $this->parseLinkParameter(
5453 $this->stripAltText( $value, $holders )
5454 );
5455 if ( $paramName ) {
5456 $validated = true;
5457 if ( $paramName === 'no-link' ) {
5458 $value = true;
5459 }
5460 if ( ( $paramName === 'link-url' ) && $this->mOptions->getExternalLinkTarget() ) {
5461 $params[$type]['link-target'] = $this->mOptions->getExternalLinkTarget();
5462 }
5463 }
5464 break;
5465 case 'frameless':
5466 case 'framed':
5467 case 'thumbnail':
5468 // use first appearing option, discard others.
5469 $validated = !$seenformat;
5470 $seenformat = true;
5471 break;
5472 default:
5473 # Most other things appear to be empty or numeric...
5474 $validated = ( $value === false || is_numeric( trim( $value ) ) );
5475 }
5476 }
5477
5478 if ( $validated ) {
5479 $params[$type][$paramName] = $value;
5480 }
5481 }
5482 }
5483 if ( !$validated ) {
5484 $caption = $part;
5485 }
5486 }
5487
5488 # Process alignment parameters
5489 if ( $params['horizAlign'] ) {
5490 $params['frame']['align'] = key( $params['horizAlign'] );
5491 }
5492 if ( $params['vertAlign'] ) {
5493 $params['frame']['valign'] = key( $params['vertAlign'] );
5494 }
5495
5496 $params['frame']['caption'] = $caption;
5497
5498 # Will the image be presented in a frame, with the caption below?
5499 $imageIsFramed = isset( $params['frame']['frame'] )
5500 || isset( $params['frame']['framed'] )
5501 || isset( $params['frame']['thumbnail'] )
5502 || isset( $params['frame']['manualthumb'] );
5503
5504 # In the old days, [[Image:Foo|text...]] would set alt text. Later it
5505 # came to also set the caption, ordinary text after the image -- which
5506 # makes no sense, because that just repeats the text multiple times in
5507 # screen readers. It *also* came to set the title attribute.
5508 # Now that we have an alt attribute, we should not set the alt text to
5509 # equal the caption: that's worse than useless, it just repeats the
5510 # text. This is the framed/thumbnail case. If there's no caption, we
5511 # use the unnamed parameter for alt text as well, just for the time be-
5512 # ing, if the unnamed param is set and the alt param is not.
5513 # For the future, we need to figure out if we want to tweak this more,
5514 # e.g., introducing a title= parameter for the title; ignoring the un-
5515 # named parameter entirely for images without a caption; adding an ex-
5516 # plicit caption= parameter and preserving the old magic unnamed para-
5517 # meter for BC; ...
5518 if ( $imageIsFramed ) { # Framed image
5519 if ( $caption === '' && !isset( $params['frame']['alt'] ) ) {
5520 # No caption or alt text, add the filename as the alt text so
5521 # that screen readers at least get some description of the image
5522 $params['frame']['alt'] = $title->getText();
5523 }
5524 # Do not set $params['frame']['title'] because tooltips don't make sense
5525 # for framed images
5526 } else { # Inline image
5527 if ( !isset( $params['frame']['alt'] ) ) {
5528 # No alt text, use the "caption" for the alt text
5529 if ( $caption !== '' ) {
5530 $params['frame']['alt'] = $this->stripAltText( $caption, $holders );
5531 } else {
5532 # No caption, fall back to using the filename for the
5533 # alt text
5534 $params['frame']['alt'] = $title->getText();
5535 }
5536 }
5537 # Use the "caption" for the tooltip text
5538 $params['frame']['title'] = $this->stripAltText( $caption, $holders );
5539 }
5540 $params['handler']['targetlang'] = $this->getTargetLanguage()->getCode();
5541
5542 Hooks::run( 'ParserMakeImageParams', [ $title, $file, &$params, $this ] );
5543
5544 # Linker does the rest
5545 $time = $options['time'] ?? false;
5546 $ret = Linker::makeImageLink( $this, $title, $file, $params['frame'], $params['handler'],
5547 $time, $descQuery, $this->mOptions->getThumbSize() );
5548
5549 # Give the handler a chance to modify the parser object
5550 if ( $handler ) {
5551 $handler->parserTransformHook( $this, $file );
5552 }
5553
5554 return $ret;
5555 }
5556
5557 /**
5558 * Parse the value of 'link' parameter in image syntax (`[[File:Foo.jpg|link=<value>]]`).
5559 *
5560 * Adds an entry to appropriate link tables.
5561 *
5562 * @since 1.32
5563 * @param string $value
5564 * @return array of `[ type, target ]`, where:
5565 * - `type` is one of:
5566 * - `null`: Given value is not a valid link target, use default
5567 * - `'no-link'`: Given value is empty, do not generate a link
5568 * - `'link-url'`: Given value is a valid external link
5569 * - `'link-title'`: Given value is a valid internal link
5570 * - `target` is:
5571 * - When `type` is `null` or `'no-link'`: `false`
5572 * - When `type` is `'link-url'`: URL string corresponding to given value
5573 * - When `type` is `'link-title'`: Title object corresponding to given value
5574 */
5575 public function parseLinkParameter( $value ) {
5576 $chars = self::EXT_LINK_URL_CLASS;
5577 $addr = self::EXT_LINK_ADDR;
5578 $prots = $this->mUrlProtocols;
5579 $type = null;
5580 $target = false;
5581 if ( $value === '' ) {
5582 $type = 'no-link';
5583 } elseif ( preg_match( "/^((?i)$prots)/", $value ) ) {
5584 if ( preg_match( "/^((?i)$prots)$addr$chars*$/u", $value, $m ) ) {
5585 $this->mOutput->addExternalLink( $value );
5586 $type = 'link-url';
5587 $target = $value;
5588 }
5589 } else {
5590 $linkTitle = Title::newFromText( $value );
5591 if ( $linkTitle ) {
5592 $this->mOutput->addLink( $linkTitle );
5593 $type = 'link-title';
5594 $target = $linkTitle;
5595 }
5596 }
5597 return [ $type, $target ];
5598 }
5599
5600 /**
5601 * @param string $caption
5602 * @param LinkHolderArray|bool $holders
5603 * @return mixed|string
5604 */
5605 protected function stripAltText( $caption, $holders ) {
5606 # Strip bad stuff out of the title (tooltip). We can't just use
5607 # replaceLinkHoldersText() here, because if this function is called
5608 # from replaceInternalLinks2(), mLinkHolders won't be up-to-date.
5609 if ( $holders ) {
5610 $tooltip = $holders->replaceText( $caption );
5611 } else {
5612 $tooltip = $this->replaceLinkHoldersText( $caption );
5613 }
5614
5615 # make sure there are no placeholders in thumbnail attributes
5616 # that are later expanded to html- so expand them now and
5617 # remove the tags
5618 $tooltip = $this->mStripState->unstripBoth( $tooltip );
5619 # Compatibility hack! In HTML certain entity references not terminated
5620 # by a semicolon are decoded (but not if we're in an attribute; that's
5621 # how link URLs get away without properly escaping & in queries).
5622 # But wikitext has always required semicolon-termination of entities,
5623 # so encode & where needed to avoid decode of semicolon-less entities.
5624 # See T209236 and
5625 # https://www.w3.org/TR/html5/syntax.html#named-character-references
5626 # T210437 discusses moving this workaround to Sanitizer::stripAllTags.
5627 $tooltip = preg_replace( "/
5628 & # 1. entity prefix
5629 (?= # 2. followed by:
5630 (?: # a. one of the legacy semicolon-less named entities
5631 A(?:Elig|MP|acute|circ|grave|ring|tilde|uml)|
5632 C(?:OPY|cedil)|E(?:TH|acute|circ|grave|uml)|
5633 GT|I(?:acute|circ|grave|uml)|LT|Ntilde|
5634 O(?:acute|circ|grave|slash|tilde|uml)|QUOT|REG|THORN|
5635 U(?:acute|circ|grave|uml)|Yacute|
5636 a(?:acute|c(?:irc|ute)|elig|grave|mp|ring|tilde|uml)|brvbar|
5637 c(?:cedil|edil|urren)|cent(?!erdot;)|copy(?!sr;)|deg|
5638 divide(?!ontimes;)|e(?:acute|circ|grave|th|uml)|
5639 frac(?:1(?:2|4)|34)|
5640 gt(?!c(?:c|ir)|dot|lPar|quest|r(?:a(?:pprox|rr)|dot|eq(?:less|qless)|less|sim);)|
5641 i(?:acute|circ|excl|grave|quest|uml)|laquo|
5642 lt(?!c(?:c|ir)|dot|hree|imes|larr|quest|r(?:Par|i(?:e|f|));)|
5643 m(?:acr|i(?:cro|ddot))|n(?:bsp|tilde)|
5644 not(?!in(?:E|dot|v(?:a|b|c)|)|ni(?:v(?:a|b|c)|);)|
5645 o(?:acute|circ|grave|rd(?:f|m)|slash|tilde|uml)|
5646 p(?:lusmn|ound)|para(?!llel;)|quot|r(?:aquo|eg)|
5647 s(?:ect|hy|up(?:1|2|3)|zlig)|thorn|times(?!b(?:ar|)|d;)|
5648 u(?:acute|circ|grave|ml|uml)|y(?:acute|en|uml)
5649 )
5650 (?:[^;]|$)) # b. and not followed by a semicolon
5651 # S = study, for efficiency
5652 /Sx", '&amp;', $tooltip );
5653 $tooltip = Sanitizer::stripAllTags( $tooltip );
5654
5655 return $tooltip;
5656 }
5657
5658 /**
5659 * Set a flag in the output object indicating that the content is dynamic and
5660 * shouldn't be cached.
5661 * @deprecated since 1.28; use getOutput()->updateCacheExpiry()
5662 */
5663 public function disableCache() {
5664 $this->logger->debug( "Parser output marked as uncacheable." );
5665 if ( !$this->mOutput ) {
5666 throw new MWException( __METHOD__ .
5667 " can only be called when actually parsing something" );
5668 }
5669 $this->mOutput->updateCacheExpiry( 0 ); // new style, for consistency
5670 }
5671
5672 /**
5673 * Callback from the Sanitizer for expanding items found in HTML attribute
5674 * values, so they can be safely tested and escaped.
5675 *
5676 * @param string &$text
5677 * @param bool|PPFrame $frame
5678 * @return string
5679 */
5680 public function attributeStripCallback( &$text, $frame = false ) {
5681 $text = $this->replaceVariables( $text, $frame );
5682 $text = $this->mStripState->unstripBoth( $text );
5683 return $text;
5684 }
5685
5686 /**
5687 * Accessor
5688 *
5689 * @return array
5690 */
5691 public function getTags() {
5692 $this->firstCallInit();
5693 return array_merge(
5694 array_keys( $this->mTransparentTagHooks ),
5695 array_keys( $this->mTagHooks ),
5696 array_keys( $this->mFunctionTagHooks )
5697 );
5698 }
5699
5700 /**
5701 * @since 1.32
5702 * @return array
5703 */
5704 public function getFunctionSynonyms() {
5705 $this->firstCallInit();
5706 return $this->mFunctionSynonyms;
5707 }
5708
5709 /**
5710 * @since 1.32
5711 * @return string
5712 */
5713 public function getUrlProtocols() {
5714 return $this->mUrlProtocols;
5715 }
5716
5717 /**
5718 * Replace transparent tags in $text with the values given by the callbacks.
5719 *
5720 * Transparent tag hooks are like regular XML-style tag hooks, except they
5721 * operate late in the transformation sequence, on HTML instead of wikitext.
5722 *
5723 * @param string $text
5724 *
5725 * @return string
5726 */
5727 public function replaceTransparentTags( $text ) {
5728 $matches = [];
5729 $elements = array_keys( $this->mTransparentTagHooks );
5730 $text = self::extractTagsAndParams( $elements, $text, $matches );
5731 $replacements = [];
5732
5733 foreach ( $matches as $marker => $data ) {
5734 list( $element, $content, $params, $tag ) = $data;
5735 $tagName = strtolower( $element );
5736 if ( isset( $this->mTransparentTagHooks[$tagName] ) ) {
5737 $output = call_user_func_array(
5738 $this->mTransparentTagHooks[$tagName],
5739 [ $content, $params, $this ]
5740 );
5741 } else {
5742 $output = $tag;
5743 }
5744 $replacements[$marker] = $output;
5745 }
5746 return strtr( $text, $replacements );
5747 }
5748
5749 /**
5750 * Break wikitext input into sections, and either pull or replace
5751 * some particular section's text.
5752 *
5753 * External callers should use the getSection and replaceSection methods.
5754 *
5755 * @param string $text Page wikitext
5756 * @param string|int $sectionId A section identifier string of the form:
5757 * "<flag1> - <flag2> - ... - <section number>"
5758 *
5759 * Currently the only recognised flag is "T", which means the target section number
5760 * was derived during a template inclusion parse, in other words this is a template
5761 * section edit link. If no flags are given, it was an ordinary section edit link.
5762 * This flag is required to avoid a section numbering mismatch when a section is
5763 * enclosed by "<includeonly>" (T8563).
5764 *
5765 * The section number 0 pulls the text before the first heading; other numbers will
5766 * pull the given section along with its lower-level subsections. If the section is
5767 * not found, $mode=get will return $newtext, and $mode=replace will return $text.
5768 *
5769 * Section 0 is always considered to exist, even if it only contains the empty
5770 * string. If $text is the empty string and section 0 is replaced, $newText is
5771 * returned.
5772 *
5773 * @param string $mode One of "get" or "replace"
5774 * @param string $newText Replacement text for section data.
5775 * @return string For "get", the extracted section text.
5776 * for "replace", the whole page with the section replaced.
5777 */
5778 private function extractSections( $text, $sectionId, $mode, $newText = '' ) {
5779 global $wgTitle; # not generally used but removes an ugly failure mode
5780
5781 $magicScopeVariable = $this->lock();
5782 $this->startParse( $wgTitle, new ParserOptions, self::OT_PLAIN, true );
5783 $outText = '';
5784 $frame = $this->getPreprocessor()->newFrame();
5785
5786 # Process section extraction flags
5787 $flags = 0;
5788 $sectionParts = explode( '-', $sectionId );
5789 $sectionIndex = array_pop( $sectionParts );
5790 foreach ( $sectionParts as $part ) {
5791 if ( $part === 'T' ) {
5792 $flags |= self::PTD_FOR_INCLUSION;
5793 }
5794 }
5795
5796 # Check for empty input
5797 if ( strval( $text ) === '' ) {
5798 # Only sections 0 and T-0 exist in an empty document
5799 if ( $sectionIndex == 0 ) {
5800 if ( $mode === 'get' ) {
5801 return '';
5802 }
5803
5804 return $newText;
5805 } else {
5806 if ( $mode === 'get' ) {
5807 return $newText;
5808 }
5809
5810 return $text;
5811 }
5812 }
5813
5814 # Preprocess the text
5815 $root = $this->preprocessToDom( $text, $flags );
5816
5817 # <h> nodes indicate section breaks
5818 # They can only occur at the top level, so we can find them by iterating the root's children
5819 $node = $root->getFirstChild();
5820
5821 # Find the target section
5822 if ( $sectionIndex == 0 ) {
5823 # Section zero doesn't nest, level=big
5824 $targetLevel = 1000;
5825 } else {
5826 while ( $node ) {
5827 if ( $node->getName() === 'h' ) {
5828 $bits = $node->splitHeading();
5829 if ( $bits['i'] == $sectionIndex ) {
5830 $targetLevel = $bits['level'];
5831 break;
5832 }
5833 }
5834 if ( $mode === 'replace' ) {
5835 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5836 }
5837 $node = $node->getNextSibling();
5838 }
5839 }
5840
5841 if ( !$node ) {
5842 # Not found
5843 if ( $mode === 'get' ) {
5844 return $newText;
5845 } else {
5846 return $text;
5847 }
5848 }
5849
5850 # Find the end of the section, including nested sections
5851 do {
5852 if ( $node->getName() === 'h' ) {
5853 $bits = $node->splitHeading();
5854 $curLevel = $bits['level'];
5855 if ( $bits['i'] != $sectionIndex && $curLevel <= $targetLevel ) {
5856 break;
5857 }
5858 }
5859 if ( $mode === 'get' ) {
5860 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5861 }
5862 $node = $node->getNextSibling();
5863 } while ( $node );
5864
5865 # Write out the remainder (in replace mode only)
5866 if ( $mode === 'replace' ) {
5867 # Output the replacement text
5868 # Add two newlines on -- trailing whitespace in $newText is conventionally
5869 # stripped by the editor, so we need both newlines to restore the paragraph gap
5870 # Only add trailing whitespace if there is newText
5871 if ( $newText != "" ) {
5872 $outText .= $newText . "\n\n";
5873 }
5874
5875 while ( $node ) {
5876 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5877 $node = $node->getNextSibling();
5878 }
5879 }
5880
5881 if ( is_string( $outText ) ) {
5882 # Re-insert stripped tags
5883 $outText = rtrim( $this->mStripState->unstripBoth( $outText ) );
5884 }
5885
5886 return $outText;
5887 }
5888
5889 /**
5890 * This function returns the text of a section, specified by a number ($section).
5891 * A section is text under a heading like == Heading == or \<h1\>Heading\</h1\>, or
5892 * the first section before any such heading (section 0).
5893 *
5894 * If a section contains subsections, these are also returned.
5895 *
5896 * @param string $text Text to look in
5897 * @param string|int $sectionId Section identifier as a number or string
5898 * (e.g. 0, 1 or 'T-1').
5899 * @param string $defaultText Default to return if section is not found
5900 *
5901 * @return string Text of the requested section
5902 */
5903 public function getSection( $text, $sectionId, $defaultText = '' ) {
5904 return $this->extractSections( $text, $sectionId, 'get', $defaultText );
5905 }
5906
5907 /**
5908 * This function returns $oldtext after the content of the section
5909 * specified by $section has been replaced with $text. If the target
5910 * section does not exist, $oldtext is returned unchanged.
5911 *
5912 * @param string $oldText Former text of the article
5913 * @param string|int $sectionId Section identifier as a number or string
5914 * (e.g. 0, 1 or 'T-1').
5915 * @param string $newText Replacing text
5916 *
5917 * @return string Modified text
5918 */
5919 public function replaceSection( $oldText, $sectionId, $newText ) {
5920 return $this->extractSections( $oldText, $sectionId, 'replace', $newText );
5921 }
5922
5923 /**
5924 * Get the ID of the revision we are parsing
5925 *
5926 * The return value will be either:
5927 * - a) Positive, indicating a specific revision ID (current or old)
5928 * - b) Zero, meaning the revision ID is specified by getCurrentRevisionCallback()
5929 * - c) Null, meaning the parse is for preview mode and there is no revision
5930 *
5931 * @return int|null
5932 */
5933 public function getRevisionId() {
5934 return $this->mRevisionId;
5935 }
5936
5937 /**
5938 * Get the revision object for $this->mRevisionId
5939 *
5940 * @return Revision|null Either a Revision object or null
5941 * @since 1.23 (public since 1.23)
5942 */
5943 public function getRevisionObject() {
5944 if ( $this->mRevisionObject ) {
5945 return $this->mRevisionObject;
5946 }
5947
5948 // NOTE: try to get the RevisionObject even if mRevisionId is null.
5949 // This is useful when parsing a revision that has not yet been saved.
5950 // However, if we get back a saved revision even though we are in
5951 // preview mode, we'll have to ignore it, see below.
5952 // NOTE: This callback may be used to inject an OLD revision that was
5953 // already loaded, so "current" is a bit of a misnomer. We can't just
5954 // skip it if mRevisionId is set.
5955 $rev = call_user_func(
5956 $this->mOptions->getCurrentRevisionCallback(),
5957 $this->getTitle(),
5958 $this
5959 );
5960
5961 if ( $this->mRevisionId === null && $rev && $rev->getId() ) {
5962 // We are in preview mode (mRevisionId is null), and the current revision callback
5963 // returned an existing revision. Ignore it and return null, it's probably the page's
5964 // current revision, which is not what we want here. Note that we do want to call the
5965 // callback to allow the unsaved revision to be injected here, e.g. for
5966 // self-transclusion previews.
5967 return null;
5968 }
5969
5970 // If the parse is for a new revision, then the callback should have
5971 // already been set to force the object and should match mRevisionId.
5972 // If not, try to fetch by mRevisionId for sanity.
5973 if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
5974 $rev = Revision::newFromId( $this->mRevisionId );
5975 }
5976
5977 $this->mRevisionObject = $rev;
5978
5979 return $this->mRevisionObject;
5980 }
5981
5982 /**
5983 * Get the timestamp associated with the current revision, adjusted for
5984 * the default server-local timestamp
5985 * @return string TS_MW timestamp
5986 */
5987 public function getRevisionTimestamp() {
5988 if ( $this->mRevisionTimestamp !== null ) {
5989 return $this->mRevisionTimestamp;
5990 }
5991
5992 # Use specified revision timestamp, falling back to the current timestamp
5993 $revObject = $this->getRevisionObject();
5994 $timestamp = $revObject ? $revObject->getTimestamp() : $this->mOptions->getTimestamp();
5995 $this->mOutput->setRevisionTimestampUsed( $timestamp ); // unadjusted time zone
5996
5997 # The cryptic '' timezone parameter tells to use the site-default
5998 # timezone offset instead of the user settings.
5999 # Since this value will be saved into the parser cache, served
6000 # to other users, and potentially even used inside links and such,
6001 # it needs to be consistent for all visitors.
6002 $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' );
6003
6004 return $this->mRevisionTimestamp;
6005 }
6006
6007 /**
6008 * Get the name of the user that edited the last revision
6009 *
6010 * @return string User name
6011 */
6012 public function getRevisionUser() {
6013 if ( is_null( $this->mRevisionUser ) ) {
6014 $revObject = $this->getRevisionObject();
6015
6016 # if this template is subst: the revision id will be blank,
6017 # so just use the current user's name
6018 if ( $revObject ) {
6019 $this->mRevisionUser = $revObject->getUserText();
6020 } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
6021 $this->mRevisionUser = $this->getUser()->getName();
6022 }
6023 }
6024 return $this->mRevisionUser;
6025 }
6026
6027 /**
6028 * Get the size of the revision
6029 *
6030 * @return int|null Revision size
6031 */
6032 public function getRevisionSize() {
6033 if ( is_null( $this->mRevisionSize ) ) {
6034 $revObject = $this->getRevisionObject();
6035
6036 # if this variable is subst: the revision id will be blank,
6037 # so just use the parser input size, because the own substituation
6038 # will change the size.
6039 if ( $revObject ) {
6040 $this->mRevisionSize = $revObject->getSize();
6041 } else {
6042 $this->mRevisionSize = $this->mInputSize;
6043 }
6044 }
6045 return $this->mRevisionSize;
6046 }
6047
6048 /**
6049 * Mutator for $mDefaultSort
6050 *
6051 * @param string $sort New value
6052 */
6053 public function setDefaultSort( $sort ) {
6054 $this->mDefaultSort = $sort;
6055 $this->mOutput->setProperty( 'defaultsort', $sort );
6056 }
6057
6058 /**
6059 * Accessor for $mDefaultSort
6060 * Will use the empty string if none is set.
6061 *
6062 * This value is treated as a prefix, so the
6063 * empty string is equivalent to sorting by
6064 * page name.
6065 *
6066 * @return string
6067 */
6068 public function getDefaultSort() {
6069 if ( $this->mDefaultSort !== false ) {
6070 return $this->mDefaultSort;
6071 } else {
6072 return '';
6073 }
6074 }
6075
6076 /**
6077 * Accessor for $mDefaultSort
6078 * Unlike getDefaultSort(), will return false if none is set
6079 *
6080 * @return string|bool
6081 */
6082 public function getCustomDefaultSort() {
6083 return $this->mDefaultSort;
6084 }
6085
6086 private static function getSectionNameFromStrippedText( $text ) {
6087 $text = Sanitizer::normalizeSectionNameWhitespace( $text );
6088 $text = Sanitizer::decodeCharReferences( $text );
6089 $text = self::normalizeSectionName( $text );
6090 return $text;
6091 }
6092
6093 private static function makeAnchor( $sectionName ) {
6094 return '#' . Sanitizer::escapeIdForLink( $sectionName );
6095 }
6096
6097 private function makeLegacyAnchor( $sectionName ) {
6098 $fragmentMode = $this->svcOptions->get( 'FragmentMode' );
6099 if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) {
6100 // ForAttribute() and ForLink() are the same for legacy encoding
6101 $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK );
6102 } else {
6103 $id = Sanitizer::escapeIdForLink( $sectionName );
6104 }
6105
6106 return "#$id";
6107 }
6108
6109 /**
6110 * Try to guess the section anchor name based on a wikitext fragment
6111 * presumably extracted from a heading, for example "Header" from
6112 * "== Header ==".
6113 *
6114 * @param string $text
6115 * @return string Anchor (starting with '#')
6116 */
6117 public function guessSectionNameFromWikiText( $text ) {
6118 # Strip out wikitext links(they break the anchor)
6119 $text = $this->stripSectionName( $text );
6120 $sectionName = self::getSectionNameFromStrippedText( $text );
6121 return self::makeAnchor( $sectionName );
6122 }
6123
6124 /**
6125 * Same as guessSectionNameFromWikiText(), but produces legacy anchors
6126 * instead, if possible. For use in redirects, since various versions
6127 * of Microsoft browsers interpret Location: headers as something other
6128 * than UTF-8, resulting in breakage.
6129 *
6130 * @param string $text The section name
6131 * @return string Anchor (starting with '#')
6132 */
6133 public function guessLegacySectionNameFromWikiText( $text ) {
6134 # Strip out wikitext links(they break the anchor)
6135 $text = $this->stripSectionName( $text );
6136 $sectionName = self::getSectionNameFromStrippedText( $text );
6137 return $this->makeLegacyAnchor( $sectionName );
6138 }
6139
6140 /**
6141 * Like guessSectionNameFromWikiText(), but takes already-stripped text as input.
6142 * @param string $text Section name (plain text)
6143 * @return string Anchor (starting with '#')
6144 */
6145 public static function guessSectionNameFromStrippedText( $text ) {
6146 $sectionName = self::getSectionNameFromStrippedText( $text );
6147 return self::makeAnchor( $sectionName );
6148 }
6149
6150 /**
6151 * Apply the same normalization as code making links to this section would
6152 *
6153 * @param string $text
6154 * @return string
6155 */
6156 private static function normalizeSectionName( $text ) {
6157 # T90902: ensure the same normalization is applied for IDs as to links
6158 $titleParser = MediaWikiServices::getInstance()->getTitleParser();
6159 try {
6160
6161 $parts = $titleParser->splitTitleString( "#$text" );
6162 } catch ( MalformedTitleException $ex ) {
6163 return $text;
6164 }
6165 return $parts['fragment'];
6166 }
6167
6168 /**
6169 * Strips a text string of wikitext for use in a section anchor
6170 *
6171 * Accepts a text string and then removes all wikitext from the
6172 * string and leaves only the resultant text (i.e. the result of
6173 * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
6174 * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
6175 * to create valid section anchors by mimicing the output of the
6176 * parser when headings are parsed.
6177 *
6178 * @param string $text Text string to be stripped of wikitext
6179 * for use in a Section anchor
6180 * @return string Filtered text string
6181 */
6182 public function stripSectionName( $text ) {
6183 # Strip internal link markup
6184 $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
6185 $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
6186
6187 # Strip external link markup
6188 # @todo FIXME: Not tolerant to blank link text
6189 # I.E. [https://www.mediawiki.org] will render as [1] or something depending
6190 # on how many empty links there are on the page - need to figure that out.
6191 $text = preg_replace( '/\[(?i:' . $this->mUrlProtocols . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
6192
6193 # Parse wikitext quotes (italics & bold)
6194 $text = $this->doQuotes( $text );
6195
6196 # Strip HTML tags
6197 $text = StringUtils::delimiterReplace( '<', '>', '', $text );
6198 return $text;
6199 }
6200
6201 /**
6202 * strip/replaceVariables/unstrip for preprocessor regression testing
6203 *
6204 * @param string $text
6205 * @param Title $title
6206 * @param ParserOptions $options
6207 * @param int $outputType
6208 *
6209 * @return string
6210 */
6211 public function testSrvus( $text, Title $title, ParserOptions $options,
6212 $outputType = self::OT_HTML
6213 ) {
6214 $magicScopeVariable = $this->lock();
6215 $this->startParse( $title, $options, $outputType, true );
6216
6217 $text = $this->replaceVariables( $text );
6218 $text = $this->mStripState->unstripBoth( $text );
6219 $text = Sanitizer::removeHTMLtags( $text );
6220 return $text;
6221 }
6222
6223 /**
6224 * @param string $text
6225 * @param Title $title
6226 * @param ParserOptions $options
6227 * @return string
6228 */
6229 public function testPst( $text, Title $title, ParserOptions $options ) {
6230 return $this->preSaveTransform( $text, $title, $options->getUser(), $options );
6231 }
6232
6233 /**
6234 * @param string $text
6235 * @param Title $title
6236 * @param ParserOptions $options
6237 * @return string
6238 */
6239 public function testPreprocess( $text, Title $title, ParserOptions $options ) {
6240 return $this->testSrvus( $text, $title, $options, self::OT_PREPROCESS );
6241 }
6242
6243 /**
6244 * Call a callback function on all regions of the given text that are not
6245 * inside strip markers, and replace those regions with the return value
6246 * of the callback. For example, with input:
6247 *
6248 * aaa<MARKER>bbb
6249 *
6250 * This will call the callback function twice, with 'aaa' and 'bbb'. Those
6251 * two strings will be replaced with the value returned by the callback in
6252 * each case.
6253 *
6254 * @param string $s
6255 * @param callable $callback
6256 *
6257 * @return string
6258 */
6259 public function markerSkipCallback( $s, $callback ) {
6260 $i = 0;
6261 $out = '';
6262 while ( $i < strlen( $s ) ) {
6263 $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
6264 if ( $markerStart === false ) {
6265 $out .= call_user_func( $callback, substr( $s, $i ) );
6266 break;
6267 } else {
6268 $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
6269 $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
6270 if ( $markerEnd === false ) {
6271 $out .= substr( $s, $markerStart );
6272 break;
6273 } else {
6274 $markerEnd += strlen( self::MARKER_SUFFIX );
6275 $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
6276 $i = $markerEnd;
6277 }
6278 }
6279 }
6280 return $out;
6281 }
6282
6283 /**
6284 * Remove any strip markers found in the given text.
6285 *
6286 * @param string $text
6287 * @return string
6288 */
6289 public function killMarkers( $text ) {
6290 return $this->mStripState->killMarkers( $text );
6291 }
6292
6293 /**
6294 * Save the parser state required to convert the given half-parsed text to
6295 * HTML. "Half-parsed" in this context means the output of
6296 * recursiveTagParse() or internalParse(). This output has strip markers
6297 * from replaceVariables (extensionSubstitution() etc.), and link
6298 * placeholders from replaceLinkHolders().
6299 *
6300 * Returns an array which can be serialized and stored persistently. This
6301 * array can later be loaded into another parser instance with
6302 * unserializeHalfParsedText(). The text can then be safely incorporated into
6303 * the return value of a parser hook.
6304 *
6305 * @deprecated since 1.31
6306 * @param string $text
6307 *
6308 * @return array
6309 */
6310 public function serializeHalfParsedText( $text ) {
6311 wfDeprecated( __METHOD__, '1.31' );
6312 $data = [
6313 'text' => $text,
6314 'version' => self::HALF_PARSED_VERSION,
6315 'stripState' => $this->mStripState->getSubState( $text ),
6316 'linkHolders' => $this->mLinkHolders->getSubArray( $text )
6317 ];
6318 return $data;
6319 }
6320
6321 /**
6322 * Load the parser state given in the $data array, which is assumed to
6323 * have been generated by serializeHalfParsedText(). The text contents is
6324 * extracted from the array, and its markers are transformed into markers
6325 * appropriate for the current Parser instance. This transformed text is
6326 * returned, and can be safely included in the return value of a parser
6327 * hook.
6328 *
6329 * If the $data array has been stored persistently, the caller should first
6330 * check whether it is still valid, by calling isValidHalfParsedText().
6331 *
6332 * @deprecated since 1.31
6333 * @param array $data Serialized data
6334 * @throws MWException
6335 * @return string
6336 */
6337 public function unserializeHalfParsedText( $data ) {
6338 wfDeprecated( __METHOD__, '1.31' );
6339 if ( !isset( $data['version'] ) || $data['version'] != self::HALF_PARSED_VERSION ) {
6340 throw new MWException( __METHOD__ . ': invalid version' );
6341 }
6342
6343 # First, extract the strip state.
6344 $texts = [ $data['text'] ];
6345 $texts = $this->mStripState->merge( $data['stripState'], $texts );
6346
6347 # Now renumber links
6348 $texts = $this->mLinkHolders->mergeForeign( $data['linkHolders'], $texts );
6349
6350 # Should be good to go.
6351 return $texts[0];
6352 }
6353
6354 /**
6355 * Returns true if the given array, presumed to be generated by
6356 * serializeHalfParsedText(), is compatible with the current version of the
6357 * parser.
6358 *
6359 * @deprecated since 1.31
6360 * @param array $data
6361 *
6362 * @return bool
6363 */
6364 public function isValidHalfParsedText( $data ) {
6365 wfDeprecated( __METHOD__, '1.31' );
6366 return isset( $data['version'] ) && $data['version'] == self::HALF_PARSED_VERSION;
6367 }
6368
6369 /**
6370 * Parsed a width param of imagelink like 300px or 200x300px
6371 *
6372 * @param string $value
6373 * @param bool $parseHeight
6374 *
6375 * @return array
6376 * @since 1.20
6377 */
6378 public static function parseWidthParam( $value, $parseHeight = true ) {
6379 $parsedWidthParam = [];
6380 if ( $value === '' ) {
6381 return $parsedWidthParam;
6382 }
6383 $m = [];
6384 # (T15500) In both cases (width/height and width only),
6385 # permit trailing "px" for backward compatibility.
6386 if ( $parseHeight && preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
6387 $width = intval( $m[1] );
6388 $height = intval( $m[2] );
6389 $parsedWidthParam['width'] = $width;
6390 $parsedWidthParam['height'] = $height;
6391 } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
6392 $width = intval( $value );
6393 $parsedWidthParam['width'] = $width;
6394 }
6395 return $parsedWidthParam;
6396 }
6397
6398 /**
6399 * Lock the current instance of the parser.
6400 *
6401 * This is meant to stop someone from calling the parser
6402 * recursively and messing up all the strip state.
6403 *
6404 * @throws MWException If parser is in a parse
6405 * @return ScopedCallback The lock will be released once the return value goes out of scope.
6406 */
6407 protected function lock() {
6408 if ( $this->mInParse ) {
6409 throw new MWException( "Parser state cleared while parsing. "
6410 . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
6411 }
6412
6413 // Save the backtrace when locking, so that if some code tries locking again,
6414 // we can print the lock owner's backtrace for easier debugging
6415 $e = new Exception;
6416 $this->mInParse = $e->getTraceAsString();
6417
6418 $recursiveCheck = new ScopedCallback( function () {
6419 $this->mInParse = false;
6420 } );
6421
6422 return $recursiveCheck;
6423 }
6424
6425 /**
6426 * Strip outer <p></p> tag from the HTML source of a single paragraph.
6427 *
6428 * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
6429 * or if there is more than one <p/> tag in the input HTML.
6430 *
6431 * @param string $html
6432 * @return string
6433 * @since 1.24
6434 */
6435 public static function stripOuterParagraph( $html ) {
6436 $m = [];
6437 if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) && strpos( $m[1], '</p>' ) === false ) {
6438 $html = $m[1];
6439 }
6440
6441 return $html;
6442 }
6443
6444 /**
6445 * Return this parser if it is not doing anything, otherwise
6446 * get a fresh parser. You can use this method by doing
6447 * $newParser = $oldParser->getFreshParser(), or more simply
6448 * $oldParser->getFreshParser()->parse( ... );
6449 * if you're unsure if $oldParser is safe to use.
6450 *
6451 * @since 1.24
6452 * @return Parser A parser object that is not parsing anything
6453 */
6454 public function getFreshParser() {
6455 if ( $this->mInParse ) {
6456 return $this->factory->create();
6457 } else {
6458 return $this;
6459 }
6460 }
6461
6462 /**
6463 * Set's up the PHP implementation of OOUI for use in this request
6464 * and instructs OutputPage to enable OOUI for itself.
6465 *
6466 * @since 1.26
6467 */
6468 public function enableOOUI() {
6469 OutputPage::setupOOUI();
6470 $this->mOutput->setEnableOOUI( true );
6471 }
6472
6473 /**
6474 * @param string $flag
6475 * @param string $reason
6476 */
6477 protected function setOutputFlag( $flag, $reason ) {
6478 $this->mOutput->setFlag( $flag );
6479 $name = $this->mTitle->getPrefixedText();
6480 $this->logger->debug( __METHOD__ . ": set $flag flag on '$name'; $reason" );
6481 }
6482 }